Архитектура обработки ошибок в Go: границы и паттерны
Обрабатывайте ошибки на правильном уровне абстракции.
Обработка ошибок в Go — тема, о которой легко рассуждать с критикой. Каждый разработчик на Go писал этот код сотни раз:
if err != nil {
return err
}
В этом нет ничего интересного. Интересная часть — это то, что означает ошибка: где её следует обрабатывать, где оборачивать, где транслировать, где логировать и что именно следует раскрыть вызывающему коду — это вопрос архитектуры.
Go рассматривает ошибки как значения. Это делает сбои явными. Но это также означает, что вашей кодовой базе нужна чёткая архитектура обработки ошибок. Без неё ошибки превращаются в случайные строки, HTTP-обработчики раскрывают детали работы с базой данных, логи дублируют один и тот же сбой пять раз, повторные попытки выполняются по неправильным причинам, а вызывающий код анализирует текст, а не поведение.

Эта статья — не вводное руководство для новичков по if err != nil.
Это практический гид по архитектуре обработки ошибок в Go: оборачивание, сигнальные ошибки, пользовательские типы ошибок, errors.Is, errors.As, границы обработки, отображение в API, логирование, повторные попытки, безопасность и паттерны для продакшена.
Чуть более категоричная версия: не пытайтесь сделать так, чтобы ошибки в Go исчезли. Сделайте их осмысленными на правильной границе.
Что такое ошибки в Go
В Go ошибка — это просто значение, реализующее этот интерфейс:
type error interface {
Error() string
}
Именно этот небольшой интерфейс делает обработку ошибок в Go такой прямой и понятной.
Функции явно возвращают ошибки:
func LoadUser(id string) (*User, error) {
// ...
}
Вызывающий код решает, что делать:
user, err := LoadUser(id)
if err != nil {
return nil, err
}
Здесь нет исключений и скрытой размотки стека. Сбой — часть сигнатуры функции.
Это хорошо, но это также означает, что ошибкам нужен дизайн. Если каждый пакет возвращает произвольные сообщения, вызывающий код не может принимать надёжные решения. Если каждый слой оборачивает каждую ошибку без дисциплины, операторы получают громоздкие сообщения, а разработчики — запутанные цепочки. Если никакой слой не оборачивает ошибки, сражения теряют контекст.
Цель — не в том, чтобы обрабатывать меньше ошибок, а в том, чтобы сделать их смысл более точным.
Три задачи ошибки
Полезная ошибка обычно выполняет одну или несколько задач.
Задача 1: Объяснить, что именно сломалось
Для людей ошибка должна объяснять, какая операция завершилась сбоем.
Пример:
return fmt.Errorf("load user %s: %w", id, err)
Это добавляет контекст. Это говорит о том, что сбой произошел при загрузке пользователя.
Задача 2: Сохранить причину
Для кода ошибка должна сохранять первопричину, если эта причина имеет значение.
Пример:
return fmt.Errorf("load user %s: %w", id, err)
%w оборачивает исходную ошибку, позволяя вызывающему коду анализировать её с помощью errors.Is или errors.As. Оборачивание имеет одну новую ловушку в Go 1.27: проверка vet за go test теперь предупреждает о fmt.Errorf("...: %w", p), когда p — это указатель на тип, который сам по себе реализует error, потому что дополнительное косвенное указание ломает проверки типов и совпадение по errors.Is. Руководство по переходу на Go 1.27 подробно разбирает это и другие проблемы, выявляемые vet после обновления тулчейна.
Задача 3: Позволить границе принять решение
На определенной границе программа должна решить, что делать.
Примеры:
- Вернуть HTTP 404
- Вернуть HTTP 409
- Повторить операцию
- Записать в лог на уровне предупреждения
- Показать пользователю безопасное сообщение
- Отменить транзакцию
- Отправить ошибку в мониторинг
- Игнорировать отмену
Это решение обычно должно основываться на идентичности или типе ошибки, а не на сопоставлении строк.
Основные инструменты для работы с ошибками в современном Go
Современный Go дает вам небольшой, но мощный набор инструментов.
errors.New
Используйте errors.New для создания простого значения ошибки:
var ErrNotFound = errors.New("not found")
Это полезно для сигнальных ошибок.
fmt.Errorf с %w
Используйте fmt.Errorf с %w для оборачивания ошибки:
return fmt.Errorf("query user: %w", err)
Оборачивание добавляет контекст, сохраняя при этом исходную ошибку для дальнейшего анализа.
errors.Is
Используйте errors.Is, чтобы проверить, соответствует ли ошибка определенной цели где-то в своей цепочке:
if errors.Is(err, ErrNotFound) {
// обработка "not found"
}
Используйте это для сигнальных ошибок и известных состояний.
errors.As
Используйте errors.As, чтобы извлечь конкретный тип ошибки из цепочки:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// использовать validationErr.Field или validationErr.Reason
}
Используйте это, когда ошибка содержит структурированные данные.
errors.Join
Используйте errors.Join, когда произошло несколько ошибок и все они должны быть сохранены:
return errors.Join(closeErr, flushErr)
Составные ошибки по-прежнему можно анализировать с помощью errors.Is и errors.As.
Используйте это осторожно. Составная ошибка означает, что несколько сбоев являются частью одного результата.
Сигнальные ошибки
Сигнальная ошибка — это значение ошибки на уровне пакета, представляющее известное состояние.
Пример:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Сигнальные ошибки полезны, когда вызывающему коду нужно знать только категорию сбоя.
Пример:
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.queryUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
Затем сервис или обработчик может проверить:
if errors.Is(err, ErrUserNotFound) {
// вернуть 404
}
Когда использовать сигнальные ошибки
Используйте сигнальные ошибки, когда:
- Состояние стабильно.
- Вызывающему коду нужно разветвление на основе него.
- Нет необходимости в дополнительных структурированных данных.
- Ошибка принадлежит вашему пакету или домену.
Хорошие примеры:
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Когда не использовать сигнальные ошибки
Не создавайте сигнальные ошибки для каждого возможного сбоя.
Плохо:
var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")
Если вызывающий код не делает разветвление по ним, они могут быть просто сообщениями.
Также будьте осторожны с экспортированием слишком большого количества сигнальных ошибок. Экспортированные сигнальные ошибки становятся частью API вашего пакета.
Пользовательские типы ошибок
Пользовательский тип ошибки полезен, когда ошибка содержит структурированную информацию.
Пример:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Вызывающий код:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
Это лучше, чем разбор строки ошибки.
Когда использовать пользовательские типы ошибок
Используйте пользовательские типы ошибок, когда:
- Вызывающему коду нужны структурированные данные.
- Ошибка имеет осмысленные поля.
- Тип является частью контракта вашего пакета.
- Вызывающему коду может потребоваться обрабатывать несколько значений по-разному.
Примеры:
- Ошибка валидации с именем поля
- Ошибка ограничения частоты с временем повторной попытки
- HTTP-ошибка с кодом состояния
- Ошибка разбора с номером строки и столбца
- Ошибка домена с идентификатором ресурса
Когда не использовать пользовательские типы ошибок
Не создавайте пользовательские типы просто для того, чтобы избежать errors.New.
Это не обязательно:
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
Если нет полезных данных, сигнальной ошибки часто достаточно.
Оборачивание ошибок
Оборачивание добавляет контекст к ошибке, сохраняя при этом исходную ошибку.
Пример:
func LoadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := parseConfig(data); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
Если os.ReadFile завершается с ошибкой, вызывающий код получает и то, и другое:
- высокоуровневую операцию: чтение конфигурации
- низкоуровневую причину: отказ в доступе, файл не найден и т. д.
Обе доступны через цепочку ошибок, что делает последовательное оборачивание с помощью %w стоящим того.
Оборачивайте с полезным контекстом
Хорошее оборачивание говорит, какая операция завершилась сбоем:
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Плохое оборачивание добавляет шума:
return fmt.Errorf("error: %w", err)
Это не говорит вызывающему коду ничего.
Также избегайте повторения одного и того же существительного на каждом слое:
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Такой тип цепочки технически верен, но практически раздражает.
Оборачивайте там, где контекст меняет смысл. Если вы не можете объяснить одной фразой, какая операция завершилась сбоем, вероятно, вы либо оборачиваете слишком агрессивно, либо недостаточно.
Когда оборачивать, а когда нет
Это одно из самых важных архитектурных решений.
Оборачивайте при пересечении осмысленной границы
Оборачивайте, когда ошибка переходит от одной операции к более высокоуровневой операции.
Пример:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Ошибка репозитория теперь является частью операции сервиса, и этот добавленный контекст полезен, когда операторы прослеживают сбой по журналам.
Не оборачивайте просто ради того, чтобы сказать «сбой»
Плохо:
if err != nil {
return fmt.Errorf("failed: %w", err)
}
Слово «failed» обычно подразумевается самим фактом наличия ошибки.
Не оборачивайте, если вы переводите
Иногда нужно перевести одну ошибку в другую ошибку домена.
Пример:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Это намеренно скрывает детали базы данных и раскрывает состояние домена.
Вы по-прежнему можете сохранить причину, если это полезно, но делайте это намеренно.
Не раскрывайте детали реализации случайно
Если вы оборачиваете низкоуровневую ошибку с помощью %w, вызывающий код может её анализировать.
Внутри вашего приложения это обычно хорошо.
Но в публичном API пакета оборачивание может раскрыть детали реализации как часть вашего контракта.
Например, если ваш пакет оборачивает sql.ErrNoRows, вызывающий код может начать зависеть от него:
if errors.Is(err, sql.ErrNoRows) {
// вызывающий код теперь знает, что вы используете database/sql
}
Если вы можете изменить хранилище позже, предпочтите сигнальную ошибку домена:
var ErrUserNotFound = errors.New("user not found")
Затем возвращайте её из границы пакета.
Границы обработки ошибок
Самый полезный способ думать об обработке ошибок в Go — это через границы.
Граница — это место, где ошибка меняет смысл или аудиторию.
К обычным границам относятся:
- от базы данных к репозиторию
- от репозитория к сервису
- от сервиса к HTTP-обработчику
- от сервиса к CLI-команде
- от внутренней ошибки к сообщению для пользователя
- от транзиторного сбоя к решению о повторе
- от сбоя операции к событию лога
- от ошибки домена к ответу API
Архитектура ошибок — это в основном проектирование границ. Каждая граница — это точка решения, где ошибки либо набирают контекст, либо теряют детали реализации, либо переводятся в форму, с которой может работать следующий слой.
Граница репозитория
Репозиторий взаимодействует с хранилищем.
Обычно он должен переводить специфичные для базы данных ошибки в ошибки домена.
Пример:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Репозиторий скрывает sql.ErrNoRows и раскрывает ErrUserNotFound — чистая граница, означающая, что сервису не нужно знать ничего о том, как хранилище представляет состояние «не найдено».
Граница сервиса
Сервис владеет бизнес-смыслом.
Обычно он должен добавлять контекст операции и сохранять ошибки домена.
Пример:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Это сохраняет состояние домена, добавляя контекст для неожиданных ошибок.
Для более сложных бизнес-правил сервис может напрямую создавать ошибки домена:
var ErrAccountDisabled = errors.New("account disabled")
func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
// ...
return session, nil
}
Сервис — правильное место для ошибок на уровне бизнеса — создаваемых напрямую из логики домена, а не переведенных из инфраструктурных состояний.
Граница HTTP-обработчика
HTTP-обработчик переводит ошибки приложения в HTTP-ответы.
Это граница, где внутренние детали должны стать безопасными для пользователя ответами.
Веб-фреймворки различаются по тому, как они структурируют этот перевод. Echo направляет ошибки обработчиков через единый HTTPErrorHandler, тогда как обработчики Gin рендерят ошибки непосредственно в контексте запроса — компромиссы между этими двумя стилями сравниваются в Веб-фреймворки Go в 2026: сравнение net/http, chi, Gin, Echo и Fiber.
Пример:
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
writeHTTPError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Отображение ошибок:
func writeHTTPError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrAccountDisabled):
http.Error(w, "account disabled", http.StatusForbidden)
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Обработчик отображает ошибки домена в HTTP-семантику, а не раскрывает необработанные детали базы данных или внутренние ошибки. Здесь многие Go-приложения ошибаются — они либо раскрывают слишком много внутренних деталей, либо сваливают все ошибки в HTTP 500. Для полной картины паттернов обработчиков и middleware в Go API, Создание REST API в Go охватывает аутентификацию, маршрутизацию и обработку ошибок в стандартной библиотеке, Gin, Echo и Fiber.
Граница CLI
У CLI другая граница, чем у HTTP API.
В CLI ошибка должна быть полезной для человека, запускающего команду.
Пример:
func RunImport(ctx context.Context, args []string) error {
if len(args) == 0 {
return ErrMissingInputFile
}
if err := importFile(ctx, args[0]); err != nil {
return fmt.Errorf("import %s: %w", args[0], err)
}
return nil
}
На границе команды:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Отображение известных ошибок на коды выхода:
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
CLI часто может показывать больше деталей, чем публичный API, но он по-прежнему должен избегать утечки секретов.
Паттерн типа ошибок API
Для HTTP API небольшой тип ошибок на уровне приложения может быть полезен.
Пример:
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}
func (e *APIError) Unwrap() error {
return e.Err
}
Конструктор:
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Использование:
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Обработчик:
func writeAPIError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, map[string]string{
"code": apiErr.Code,
"message": apiErr.Message,
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
Этот паттерн полезен, когда вам нужны структурированные ошибки API со стабильными кодами.
Используйте его на границе API. Не заставляйте каждый внутренний пакет возвращать специфичные для API ошибки.
Ошибки домена и транспортные ошибки
Держите ошибки домена отдельно от транспортных ошибок.
Ошибка домена:
var ErrInsufficientBalance = errors.New("insufficient balance")
Транспортное отображение:
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
Не заставляйте ваш слой домена возвращать HTTP-коды состояния:
return &APIError{Status: http.StatusConflict}
Это связывает бизнес-логику с HTTP и мешает вашему сервисному слою чисто работать через HTTP, CLI, воркеры, тесты и будущие адаптеры gRPC. Транспортное отображение принадлежит на границе транспорта, а не в коде домена. Для руководств о том, где определять ошибки домена, сигнальные ошибки и транспортные адаптеры внутри структуры вашего проекта, Структура проекта Go: Практики и Паттерны охватывает соглашения internal/, pkg/ и адаптеров, которые сохраняют эти слои четко разделенными.
Повторяемые ошибки
Некоторые ошибки должны вызывать повторную попытку. Некоторые — нет.
Не принимайте это решение, сопоставляя строки.
Используйте маркерный интерфейс или явную функцию.
Пример:
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Вспомогательная функция:
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Использование:
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Цикл повторных попыток:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// повторить с экспоненциальной задержкой
}
return err
}
Это гораздо лучше, чем проверка, содержит ли строка ошибки “timeout” — сопоставление строк молча ломается при изменении сообщений и создает невидимую связь между производителем и потребителем.
Ошибки валидации
Ошибки валидации часто нуждаются в структурированных данных.
Пример:
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Использование:
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Обработчик:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
Это хорошее применение errors.As, потому что вызывающему коду нужна структурированная информация — имена полей и сообщения о валидации — а не просто непрозрачная строка ошибки.
Несколько ошибок
Иногда ломается несколько вещей.
Примеры:
- закрытие нескольких ресурсов
- валидация многих полей
- остановка нескольких воркеров
- выполнение независимых проверок
- сброс и закрытие вывода
Используйте errors.Join, когда все ошибки должны быть сохранены.
Пример:
func CloseAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Вызывающий код:
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
И errors.Is, и errors.As могут анализировать составные ошибки, что означает, что значения составных ошибок остаются полностью совместимыми со стандартными паттернами проверки ошибок.
Когда не использовать errors.Join
Не используйте errors.Join, когда есть одна основная ошибка и некоторый контекст для логирования.
Не используйте его, чтобы избежать решения, какая ошибка важна.
Не возвращайте огромные составные ошибки пользователям.
Составные ошибки полезны, но они могут быстро стать шумными.
Паника — это не обработка ошибок
В обычном коде приложения не используйте панику для ожидаемых ошибок.
Плохо:
if err != nil {
panic(err)
}
Используйте панику для ошибок программиста или действительно безвыходных ситуаций.
Примеры:
- невозможное нарушение внутренней инвариантности
- недействительная инициализация пакета
- сбой помощника тестирования с
t.Fatalили паникой в ограниченных случаях - необратимая ошибка конфигурации при запуске, в зависимости от стиля
Не используйте панику из-за того, что запрос к базе данных завершился сбоем или пользователь отправил недействительный ввод.
Это обычные ошибки.
Логирование ошибок
Частая ошибка в Go — логирование одной и той же ошибки на каждом слое.
Плохо:
func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if err != nil {
log.Printf("query failed: %v", err)
return nil, err
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
log.Printf("service failed: %v", err)
return nil, err
}
return user, nil
}
Это создает дублированные записи в логах для одного сбоя.
Лучше:
- оборачивать ошибки при их перемещении вверх
- логировать один раз на границе, где ошибка обрабатывается
- включать структурированный контекст в лог
Пример:
func (s *Server) handleError(r *http.Request, err error) {
s.logger.ErrorContext(
r.Context(),
"request failed",
"method", r.Method,
"path", r.URL.Path,
"err", err,
)
}
Это дает одно событие лога со всей цепочкой ошибок. Для готового к производству набора структурированного логирования, Структурированное логирование в Go с slog охватывает записи log/slog, обработчики JSON, корреляцию контекста и маскирование — все из чего естественно сочетается с логированием ошибок на уровне границ.
Когда логировать внутри нижних слоев
Логировать внутри нижних слоев следует только тогда, когда слой действительно обрабатывает ошибку или добавляет важный операционный контекст, который не будет виден в другом месте.
Например, цикл повторных попыток может логировать каждую попытку на уровне отладки или предупреждения.
Но репозиторий не должен логировать каждую ошибку запроса, если обработчик будет логировать финальный сбой запроса.
Ошибки для пользователей и ошибки для операторов
Не показывайте внутренние ошибки напрямую пользователям.
Внутренняя ошибка:
query user by id: dial tcp 10.0.4.12:5432: connection refused
Сообщение для пользователя:
internal server error
Лог оператора:
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
Это разные аудитории, и хорошая архитектура ошибок держит их отдельно:
- внутренняя диагностическая ошибка
- безопасный ответ для пользователя
- стабильный код ошибки API
- контекст лога оператора
Принуждение одной строки ошибки служить всем этим аудиториям приводит либо к риску раскрытия, либо к кошмару отладки. Проектируйте архитектуру ошибок вокруг отдельных значений для отдельных потребителей.
Безопасная обработка ошибок
Ошибки могут раскрывать конфиденциальную информацию.
Избегайте раскрытия:
- строк подключения к базе данных
- SQL-запросов с секретами
- внутренних имен хостов
- путей к файлам
- токенов доступа
- API-ключей
- трейсов стека
- частных данных клиентов
- деталей политики авторизации
Это особенно важно в HTTP API.
Плохо:
http.Error(w, err.Error(), http.StatusInternalServerError)
Хорошо:
http.Error(w, "internal server error", http.StatusInternalServerError)
Логгируйте внутреннюю ошибку безопасно для операторов. Возвращайте безопасное сообщение пользователю.
Коды ошибок
Для публичных API стабильные коды ошибок часто лучше, чем полагаться только на сообщения.
Пример ответа:
{
"code": "user_not_found",
"message": "user not found"
}
Сообщение может меняться. Код должен быть стабильным.
Используйте коды ошибок для:
- поведения клиента
- документации
- SDK
- локализации
- диагностики поддержки
Не заставляйте клиентов разбирать английские сообщения об ошибках.
Практический послойный дизайн обработки ошибок
Вот чистый паттерн для многих бэкенд-сервисов на Go.
Слой репозитория
- Взаимодействует с базой данных или внешним хранилищем.
- Преобразует специфичные для хранилища ошибки «не найдено» в ошибки домена.
- Оборачивает неожиданные ошибки хранилища контекстом операции.
- Не возвращает HTTP-ошибки.
- Обычно не логирует.
Пример:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Слой сервиса
- Владеет бизнес-правилами.
- Создает ошибки домена.
- Сохраняет известные ошибки домена.
- Оборачивает неожиданные низкоуровневые ошибки.
- Не возвращает HTTP-коды состояния.
- Обычно не логирует.
Пример:
if user.Disabled {
return nil, ErrAccountDisabled
}
Слой транспорта
- Отображает ошибки домена в ответы HTTP, gRPC или CLI.
- Логирует необработанные или неожиданные ошибки.
- Скрывает внутренние детали от пользователей.
- Устанавливает коды состояния и коды ошибок API.
Пример:
switch {
case errors.Is(err, ErrUserNotFound):
writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
Это разделение делает обработку ошибок понятной и позволяет каждому слою эволюционировать независимо — вы можете изменить технологию хранилища, не трогая логику сервиса или отображение транспорта. Послойный дизайн работает лучше всего, когда зависимости внедряются, а не захардкожены; Внедрение зависимостей в Go: Паттерны и Лучшие Практики охватывает паттерны конструкторов и интерфейсов, которые делают каждую границу легко тестируемой изолированно.
Полный пример
Вот небольшой пример end-to-end.
Ошибки домена:
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Репозиторий:
package users
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name, disabled
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Disabled,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Сервис:
package users
import (
"context"
"errors"
"fmt"
)
type Service struct {
repo *Repository
}
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get profile for user %s: %w", id, err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
return &Profile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
HTTP-обработчик:
package httpapi
import (
"context"
"errors"
"net/http"
"example.com/app/users"
)
type Handler struct {
users *users.Service
}
func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
if err != nil {
h.writeError(w, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, users.ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{
"code": "user_not_found",
"message": "user not found",
})
case errors.Is(err, users.ErrAccountDisabled):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "account_disabled",
"message": "account is disabled",
})
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusGatewayTimeout, map[string]string{
"code": "request_timeout",
"message": "request timed out",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
}
Эта структура дает вам:
- ошибки домена
- перевод хранилища
- контекст сервиса
- безопасное отображение HTTP
- анализируемые цепочки ошибок
- отсутствие сопоставления строк
- отсутствие утечки транспорта в код домена
Это тот тип архитектуры ошибок, который масштабируется — достаточно простой для понимания новым участником, но достаточно структурированный, чтобы логика домена никогда не утекала в транспортные ответы.
Тестирование поведения ошибок
Поведение ошибок следует тестировать так же тщательно, как и сценарии успеха, потому что решения на границах — отображение сигнальных ошибок, извлечение типов, коды HTTP — часто являются местом, где баги скрываются дольше всего. Для полного руководства по структуре тестов Go, моккам и паттернам покрытия, см. Юнит-тестирование в Go: Структура и Лучшие Практики.
Тестирование отображения сигнальных ошибок
func TestGetByIDNotFound(t *testing.T) {
repo := newTestRepository(t)
_, err := repo.GetByID(t.Context(), "missing")
if !errors.Is(err, users.ErrUserNotFound) {
t.Fatalf("got %v, want ErrUserNotFound", err)
}
}
Тестирование извлечения пользовательских ошибок
func TestValidationError(t *testing.T) {
err := ValidateCreateUser(CreateUserRequest{})
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("got %T, want ValidationError", err)
}
if len(validationErr.Fields) == 0 {
t.Fatal("expected validation fields")
}
}
Тестирование отображения HTTP
func TestWriteErrorNotFound(t *testing.T) {
rec := httptest.NewRecorder()
writeHTTPError(rec, users.ErrUserNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
Тесты должны доказывать, что известные ошибки порождают правильное поведение на каждой границе, чтобы рефакторинг слоев хранилища или транспорта не мог молча изменить контракт сбоя.
Общие антипаттерны
Антипаттерн 1: Сопоставление строк
Плохо:
if strings.Contains(err.Error(), "not found") {
// ...
}
Используйте вместо этого errors.Is или errors.As — оба автоматически обрабатывают цепочки обернутых ошибок и не ломаются, когда сообщения переформатируются или локализируются.
Антипаттерн 2: Потеря причины
Плохо:
return errors.New("query failed")
Лучше:
return fmt.Errorf("query user: %w", err)
Антипаттерн 3: Оборачивание без смысла
Плохо:
return fmt.Errorf("error happened: %w", err)
Оборачивайте с контекстом операции, объясняющим, что именно предпринималось, например "create invoice %s: %w", а не размытый префикс, не добавляющий диагностической ценности.
Антипаттерн 4: Логирование на каждом слое
Плохо:
log.Println(err)
return err
на каждом уровне. Логируйте один раз там, где ошибка в конечном счете обрабатывается, а не на каждом промежуточном слое, который просто передает ее вверх.
Антипаттерн 5: Возвращение HTTP-ошибок из кода домена
Плохо:
return &APIError{Status: http.StatusNotFound}
из сервисного домена. Отображайте ошибки домена в коды состояния HTTP и тела ответов на границе обработчика, сохраняя ваш сервисный слой независимым от транспортных вопросов.
Антипаттерн 6: Раскрытие внутренних ошибок пользователям
Плохо:
http.Error(w, err.Error(), http.StatusInternalServerError)
Возвращайте безопасные общие сообщения пользователям и логируйте полную внутреннюю ошибку с структурированным контекстом для операторов. Никогда не раскрывайте строки подключения к базе данных, пути к файлам или необработанные трейсы стека в ответах API.
Антипаттерн 7: Слишком много экспортированных сигнальных ошибок
Экспортированные ошибки являются частью API вашего пакета, и их добавление обязывает вас к их поддержанию. Не экспортируйте каждое внутреннее состояние, если внешние вызывающие коды действительно не нуждаются в разветвлении на его основе — предпочитайте держать сигнальные ошибки неэкспортированными, пока не появится явная необходимость.
Антипаттерн 8: Использование паники для ожидаемых сбоев
Плохо:
panic(err)
для обычных runtime-сбоев. Зарезервируйте панику для действительно необратимых условий или ошибок программиста, а не для отсутствующих записей или недействительного пользовательского ввода — всегда возвращайте ошибки в таких случаях.
Антипаттерн 9: Игнорирование ошибок контекста
Плохо:
return fmt.Errorf("request failed")
когда настоящей причиной была context.Canceled. Сохраняйте ошибки контекста, чтобы вызывающий код мог отличить подлинный сбой операции от отмененного или просроченного запроса и соответствующим образом реагировать на каждый. Для подробного рассмотрения того, как отмена контекста и распространение таймаутов работают через слои сервисов, см. Go context.Context Верно.
Чек-лист проверки ошибок
Используйте этот чек-лист при ревью кода.
Создание ошибки
- Является ли это известным состоянием?
- Должна ли это быть сигнальная ошибка?
- Нужны ли структурированные данные?
- Должен ли это быть пользовательский тип?
- Ясно ли сообщение об ошибке?
Оборачивание ошибок
- Добавляет ли оборачивание полезный контекст операции?
- Сохраняет ли
%wпричину там, где это нужно? - Раскрывает ли код случайно детали реализации?
- Слишком ли шумная цепочка?
Перевод ошибок
- Переводится ли низкоуровневая ошибка на правильной границе?
- Скрыты ли специфичные для базы данных особенности от кода сервиса?
- Независимы ли ошибки домена от вопросов HTTP или CLI?
Обработка ошибок
- Делает ли вызывающий код разветвление с
errors.Isилиerrors.As? - Обрабатываются ли отмена контекста и дедлайны правильно?
- Идентифицируются ли повторяемые ошибки явно?
- Структурированы ли ошибки валидации?
Логирование
- Логируется ли ошибка один раз, на границе обработки?
- Структурированы ли логи?
- Исключены ли конфиденциальные детали из ответов для пользователей?
- Есть ли достаточно контекста для операторов?
Тестирование
- Тестируются ли известные случаи ошибок?
- Тестируются ли отображения HTTP или CLI?
- Тестируются ли детали валидации?
- Тестируются ли решения о повторе?
Мои категоричные правила
Правило 1: Ошибки должны пересекать границы со смыслом
Не просто передавайте ошибки. Решайте, что они означают на каждом слое.
Правило 2: Оборачивайте для контекста, а не для украшения
Если оборачивание не добавляет полезную информацию о том, какая операция завершилась сбоем, не оборачивайте. Дополнительный слой контекста без смысла делает цепочку ошибок сложнее для чтения и не добавляет диагностической ценности.
Правило 3: Переводите ошибки реализации в ошибки домена
Не позволяйте sql.ErrNoRows становиться частью вашей бизнес-логики. Переводите ошибки реализации в ошибки домена на границе хранилища, чтобы остальной части приложения никогда не нужно было знать, какая база данных или ORM используется под капотом.
Правило 4: Не разбирайте строки ошибок
Если коду нужно разветвляться по типу сбоя, используйте сигнальные ошибки, пользовательские типы, errors.Is или errors.As. Проверка строк создает невидимую связь, которая молча ломается при изменении сообщений об ошибках.
Правило 5: Логируйте один раз
Оборачивайте, когда ошибки поднимаются. Логируйте там, где ошибка в конечном счете обрабатывается.
Правило 6: Держите сообщения для пользователей безопасными
Внутренние диагностические ошибки — для логов. Сообщения для пользователей — для пользователей.
Правило 7: Держите транспортные ошибки на границе транспорта
HTTP-коды состояния принадлежат обработчикам или API-адаптерам, а не сервисам домена. Код домена должен быть переиспользуемым через транспорт — сегодня HTTP, завтра CLI, gRPC или событийный воркер.
Финальные мысли
Обработка ошибок в Go — это не о том, чтобы вечно писать if err != nil — это о том, чтобы сделать сбой явным и понятным на каждой границе.
Механика проста:
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
Архитектура — более сложная часть:
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
Вот так и выглядит хорошо выполненная обработка ошибок в Go — не хитро, не магически, а достаточно ясно, чтобы следующий разработчик, оператор, клиент API и ваше будущее «я» могли понять, что сломалось и что должно произойти дальше. Для более широкого взгляда на производственные паттерны Go через интеграцию, тестирование и доступ к данным, см. Архитектура приложений в производстве.