Модуль 4. Обработка ошибок

Цели модуля

После модуля студент сможет:

  • Понимать тип error и идиоматичные паттерны обработки
  • Оборачивать ошибки с сохранением контекста через %w
  • Использовать errors.Is() и errors.As() для проверки ошибок
  • Проектировать sentinel errors и кастомные error types
  • Понимать когда использовать panic/recover
  • Принимать осознанные решения: error vs panic
  • Проектировать стратегию ошибок для сервиса

4.1 Тип error и паттерны

Что такое error

В Go error - это встроенный интерфейс с одним методом:

go
type error interface {
    Error() string
}

Любой тип, реализующий метод Error() string, удовлетворяет интерфейсу error.

Базовый паттерн: проверка ошибки

Go не имеет исключений. Ошибки возвращаются как значения и проверяются явно.

go
result, err := doSomething()
if err != nil {
    return fmt.Errorf("do something: %w", err)
}
// используем result

Это не многословность - это явное управление потоком ошибок. Каждый вызов, который может завершиться ошибкой, делает это явно.

Создание ошибок

go
// errors.New - простая ошибка
err := errors.New("something went wrong")

// fmt.Errorf - форматированная ошибка
err := fmt.Errorf("failed to process item %d", id)

// Кастомный тип ошибки
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message)
}

Идиоматичный стиль: error последним возвращаемым значением

go
func GetRepo(ctx context.Context, id string) (*Repo, error)
func CreateRepo(ctx context.Context, req CreateRepoRequest) (*Repo, error)
func DeleteRepo(ctx context.Context, id string) error

Правило: error всегда последний в списке возвращаемых значений.

Обработка на границе

Ошибки обрабатываются на границе системы (handler, main). Внутри бизнес-логики ошибки пробрасываются вверх с контекстом.

go
// Handler - граница, здесь обрабатываем
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    repo, err := h.usecase.Execute(r.Context(), id)
    if err != nil {
        h.handleError(w, err)  // обработка на границе
        return
    }
    // успех
}

// Usecase - пробрасывает с контекстом
func (uc *GetRepoUseCase) Execute(ctx context.Context, id string) (*models.Repo, error) {
    repo, err := uc.service.GetRepo(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    return repo, nil
}

4.2 Оборачивание ошибок

Зачем оборачивать

Оборачивание добавляет контекст к ошибке, сохраняя оригинал. Это помогает понять где произошла ошибка и почему.

go
// Без оборачивания - теряется контекст
func GetRepo(id string) (*Repo, error) {
    repo, err := db.Query(id)
    if err != nil {
        return nil, err  // не понятно где ошибка
    }
    return repo, nil
}

// С оборачиванием - контекст сохраняется
func GetRepo(id string) (*Repo, error) {
    repo, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    return repo, nil
}

Формат оборачивания

go
fmt.Errorf("операция: %w", err)

Правила:

  • Начинайте с описания операции (что делали)
  • Используйте %w для сохранения цепочки
  • Не дублируйте информацию из оригинальной ошибки
  • Добавляйте контекст (id, параметры)

Пример цепочки ошибок

text
HTTP Handler: "get repo handler: %w"
    -> Usecase: "get repo usecase: %w"
    -> Service: "get repo service: %w"
    -> Repository: "query repo: %w"
    -> pgx: "connection refused"

Итоговое сообщение: get repo handler: get repo usecase: get repo service: query repo: connection refused

Каждый слой добавил свой контекст. По цепочке понятно где именно произошла ошибка.

%w vs %v

ФорматЧто делает
%wОборачивает ошибку, сохраняет цепочку для errors.Is/As
%vФорматирует в строку, цепочка теряется
%sАналогично %v для ошибок

Всегда используйте %w для оборачивания. %v только если намеренно хотите разорвать цепочку.

Антипаттерн: логирование на каждом слое

go
// ПЛОХО: логирование на каждом слое
func (s *Service) GetRepo(ctx context.Context, id string) (*Repo, error) {
    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        log.Error("failed to get repo", "error", err)  // не здесь
        return nil, fmt.Errorf("get repo: %w", err)
    }
    return repo, nil
}

// ПРАВИЛЬНО: логирование на границе
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    repo, err := h.usecase.Execute(r.Context(), id)
    if err != nil {
        log.Error("get repo failed", "error", err, "request_id", reqID)
        h.handleError(w, err)
        return
    }
}

Логируйте один раз на границе. Внутри только оборачивайте.


4.3 errors.Is() и errors.As()

errors.Is - проверка равенства

errors.Is проверяет, является ли ошибка конкретной sentinel ошибкой. Работает через всю цепочку обёрток.

go
var ErrNotFound = errors.New("not found")

func GetRepo(id string) (*Repo, error) {
    repo, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, ErrNotFound)
    }
    return repo, nil
}

// Проверка через цепочку обёрток
repo, err := GetRepo("123")
if errors.Is(err, ErrNotFound) {
    // обработка not found
}

errors.As - извлечение типа

errors.As извлекает ошибку конкретного типа из цепочки.

go
type ValidationError struct {
    Field   string
    Message string
}

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

// Извлечение из цепочки
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("field: %s, message: %s\n", valErr.Field, valErr.Message)
}

Разница между Is и As

ФункцияНазначениеИспользование
errors.IsПроверка равенстваSentinel errors
errors.AsИзвлечение типаКастомные error types

Пример из шаблона: internal/service/repo_service.go

go
var (
    ErrRepoNotFound      = errors.New("repo not found")
    ErrRepoAlreadyExists = errors.New("repo already exists")
)

func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) {
    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    if repo == nil {
        return nil, ErrRepoNotFound
    }
    return repo, nil
}

func (s *RepoService) CreateRepo(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    existing, err := s.repo.GetByID(ctx, req.ID)
    if err != nil {
        return nil, fmt.Errorf("check existing repo: %w", err)
    }
    if existing != nil {
        return nil, ErrRepoAlreadyExists
    }

    repo := models.NewRepo(req.Name, req.OwnerID)
    if err := s.repo.Create(ctx, repo); err != nil {
        return nil, fmt.Errorf("create repo: %w", err)
    }
    return repo, nil

}

Использование в handler

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    repo, err := h.usecase.Execute(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, service.ErrRepoNotFound):
            writeError(w, http.StatusNotFound, "repo not found")
        case errors.Is(err, service.ErrRepoAlreadyExists):
            writeError(w, http.StatusConflict, "repo already exists")
        default:
            writeError(w, http.StatusInternalServerError, "internal error")
        }
        return
    }
    // успех
}

4.4 Sentinel errors vs error types

Sentinel errors

Sentinel errors - предопределённые переменные ошибок. Используются для простых проверок.

go
var (
    ErrNotFound      = errors.New("not found")
    ErrAlreadyExists = errors.New("already exists")
    ErrUnauthorized  = errors.New("unauthorized")
)

// Проверка
if errors.Is(err, ErrNotFound) { ... }

Когда использовать:

  • Простые проверки без дополнительного контекста
  • Публичные API ошибки
  • Стандартные ошибки (io.EOF, sql.ErrNoRows)

Error types

Error types - кастомные типы ошибок с дополнительными полями. Используются когда нужен контекст.

go
type NotFoundError struct {
    Entity string
    ID     string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %s not found", e.Entity, e.ID)
}

// Создание
return nil, &NotFoundError{Entity: "repo", ID: id}

// Проверка
var nfErr *NotFoundError
if errors.As(err, &nfErr) {
    fmt.Printf("entity: %s, id: %s\n", nfErr.Entity, nfErr.ID)
}

Когда использовать:

  • Нужен дополнительный контекст (id, код, детали)
  • Разные обработки в зависимости от полей ошибки
  • API ошибки с кодами и сообщениями

Сравнение

КритерийSentinelError type
ПростотаОчень простаяСложнее
КонтекстНетЕсть (поля)
Проверкаerrors.Iserrors.As
КогдаПростые проверкиНужен контекст
ПримерErrNotFoundValidationError{Field, Msg}

Пример: комбинированный подход

go
// Sentinel для простых случаев
var ErrNotFound = errors.New("not found")

// Error type для сложных случаев
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message)
}

// Использование
func ValidateRepo(repo *models.Repo) error {
    if repo.Name == "" {
        return &ValidationError{Field: "name", Message: "required"}
    }
    if len(repo.Name) > 100 {
        return &ValidationError{Field: "name", Message: "too long"}
    }
    return nil
}

4.5 Кастомные ошибки

Паттерн: доменные ошибки

Для домена создавайте отдельный файл с ошибками.

go
// internal/service/errors.go
package service

import "errors"

var (
    ErrRepoNotFound      = errors.New("repo not found")
    ErrRepoAlreadyExists = errors.New("repo already exists")
    ErrUserNotFound      = errors.New("user not found")
    ErrUnauthorized      = errors.New("unauthorized")
)

Паттерн: ошибки с кодами

Для API ошибок с кодами:

go
type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// Конструкторы
func NewNotFoundError(message string) *AppError {
    return &AppError{Code: 404, Message: message}
}

func NewValidationError(message string, err error) *AppError {
    return &AppError{Code: 400, Message: message, Err: err}
}

Паттерн: Unwrap для кастомных ошибок

Если кастомная ошибка оборачивает другую, реализуйте Unwrap:

go
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %s: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    return e.Err
}

// Теперь errors.Is и errors.As работают через QueryError
var qErr *QueryError
if errors.As(err, &qErr) {
    fmt.Println(qErr.Query)
}

4.6 panic/recover

Что такое panic

panic прерывает нормальный поток выполнения и начинает раскрутку стека. Если panic не пойман, программа падает.

go
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero")
    }
    return a / b
}

Когда panic оправдан

panic оправдан только в исключительных случаях:

  • Невозможное состояние программы (bug)

  • Ошибка инициализации при старте

  • Нарушение инвариантов

    func MustParse(s string) *Config { cfg, err := Parse(s) if err != nil { panic(fmt.Sprintf("invalid config: %v", err)) } return cfg }

recover

recover ловит panic и предотвращает падение программы. Работает только внутри defer.

go
func safeHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Error("panic recovered", "panic", rec, "stack", debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Антипаттерн: panic для обработки ошибок

go
// ПЛОХО: panic для бизнес-ошибок
func GetRepo(id string) *Repo {
    repo, err := db.Query(id)
    if err != nil {
        panic(err)  // не делайте так
    }
    return repo
}

// ПРАВИЛЬНО: возврат error
func GetRepo(id string) (*Repo, error) {
    repo, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("get repo: %w", err)
    }
    return repo, nil
}

panic в горутинах

panic в горутине не может быть пойман recover в другой горутине. Каждая горутина должна иметь свой recover.

go
go func() {
    defer func() {
        if rec := recover(); rec != nil {
            log.Error("panic in goroutine", "panic", rec)
        }
    }()
    doWork()
}()

4.7 Когда возвращать error, а когда паниковать

Правило

Возвращайте error для ожидаемых ошибок. Паникуйте только для невозможных состояний.

Ожидаемые ошибки -> return error

  • Файл не найден
  • Запись не найдена в БД
  • Невалидный ввод пользователя
  • Сетевая ошибка
  • Таймаут
go
// Ожидаемая ошибка - return error
func GetRepo(id string) (*Repo, error) {
    repo, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("get repo: %w", err)
    }
    return repo, nil
}

Невозможные состояния -> panic

  • Bug в коде (нарушение инварианта)
  • Ошибка инициализации при старте
  • Невозможная ветка в switch
go
// Невозможное состояние - panic
func processStatus(status Status) {
    switch status {
    case StatusActive:
    // ...
    case StatusArchived:
    // ...
    default:
    panic(fmt.Sprintf("unknown status: %d", status))
    }
}

Таблица решений

СитуацияЧто делать
Файл не найденreturn error
Невалидный вводreturn error
Запись не найденаreturn error
Сетевая ошибкаreturn error
Bug в кодеpanic
Ошибка инициализацииpanic / log.Fatal
Невозможная веткаpanic
Ошибка в тестеt.Fatal

Привязка к шаблону проекта

Структура ошибок в шаблоне

text
internal/service/
├── errors.go           # Sentinel errors для домена
├── repo_service.go     # Использование ошибок
├── user_service.go
└── ...

internal/usecase/
├── create_repo_usecase.go  # Проброс ошибок с контекстом
├── get_repo_usecase.go
└── ...

Что изучаем на примере шаблона

  1. Sentinel errors определены в internal/service/errors.go
  2. Сервисы оборачивают ошибки с контекстом операции
  3. Use cases пробрасывают ошибки с дополнительным контекстом
  4. Handlers обрабатывают ошибки на границе (errors.Is)
  5. Логирование происходит только на границе

Задание: изучите internal/service/

  • Какие sentinel errors определены?
  • Как ошибки оборачиваются в сервисах?
  • Где происходит логирование ошибок?
  • Как handler определяет тип ошибки (errors.Is)?

💱 Компромиссы

  • %w vs %v при оборачивании: %w сохраняет цепочку для errors.Is/errors.As, но сообщение растёт с каждым слоем; %v рвёт цепочку — проверка на ErrNotFound больше не сработает, зато текст короче и контролируемее.
  • Sentinel errors vs error types: ErrRepoNotFound проверяется одним errors.Is, но не несёт контекста; ValidationError{Field} несёт поля и коды, но требует объявления типа и errors.As.
  • error vs panic: возврат error делает ожидаемые сбои явными в сигнатуре, но плодит if err != nil; panic лаконичен, однако в горутине без recover валит весь процесс, поэтому оставлен невозможным состояниям.
  • Логирование на границе vs в каждом слое: единый лог в handler пишет ошибку один раз с request_id, но требует доверять обёрткам; лог в каждом слое информативнее локально, однако дублирует записи и шумит в мониторинге.
  • Recovery middleware vs отказ от recover: recover в defer превращает panic в 500 и сохраняет сервер живым, но глушит баг, если стек не попал в лог; без recover падает весь процесс, зато падение видно сразу и со стеком.

Практическое задание

Задание 1: Sentinel errors

Создайте файл internal/service/errors.go:

go
package service

import "errors"

var (
    ErrRepoNotFound      = errors.New("repo not found")
    ErrRepoAlreadyExists = errors.New("repo already exists")
    ErrUserNotFound      = errors.New("user not found")
    ErrUnauthorized      = errors.New("unauthorized")
    ErrInvalidInput      = errors.New("invalid input")
)

Задание 2: Кастомный error type

Создайте кастомную ошибку валидации:

go
package service

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message)
}

func NewValidationError(field, message string) *ValidationError {
    return &ValidationError{Field: field, Message: message}
}

Задание 3: Сервис с обработкой ошибок

Реализуйте RepoService с правильным оборачиванием:

go
package service

import (
    "context"
    "fmt"
    "yourproject/internal/models"
    "yourproject/internal/repository"
)

type RepoService struct {
    repo repository.RepoRepository
}

func NewRepoService(repo repository.RepoRepository) *RepoService {
    return &RepoService{repo: repo}
}

func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) {
    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    if repo == nil {
        return nil, ErrRepoNotFound
    }
    return repo, nil
}

func (s *RepoService) CreateRepo(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    if err := req.Validate(); err != nil {
        return nil, fmt.Errorf("validate request: %w", err)
    }

    existing, err := s.repo.GetByID(ctx, req.ID)
    if err != nil {
        return nil, fmt.Errorf("check existing repo: %w", err)
    }
    if existing != nil {
        return nil, ErrRepoAlreadyExists
    }

    repo := models.NewRepo(req.Name, req.OwnerID)
    if err := s.repo.Create(ctx, repo); err != nil {
        return nil, fmt.Errorf("create repo: %w", err)
    }
    return repo, nil
}

Задание 4: Обработка в handler

Реализуйте handler с обработкой ошибок:

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    repo, err := h.usecase.Execute(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, service.ErrRepoNotFound):
            writeJSON(w, http.StatusNotFound, ErrorResponse{
                Code:    "NOT_FOUND",
                Message: "repo not found",
            })
        case errors.Is(err, service.ErrRepoAlreadyExists):
            writeJSON(w, http.StatusConflict, ErrorResponse{
                Code:    "CONFLICT",
                Message: "repo already exists",
            })
        default:
            log.Error("unexpected error", "error", err, "request_id", getRequestID(r))
            writeJSON(w, http.StatusInternalServerError, ErrorResponse{
                Code:    "INTERNAL",
                Message: "internal server error",
            })
        }
        return
    }

    writeJSON(w, http.StatusOK, repo)
}

Задание 5: Тесты для ошибок

Напишите тесты с проверкой ошибок:

go
func TestRepoService_GetRepo_NotFound(t *testing.T) {
    mockRepo := NewMockRepoRepository(t)
    mockRepo.On("GetByID", mock.Anything, "repo-123").
        Return(nil, nil)

    svc := NewRepoService(mockRepo)
    repo, err := svc.GetRepo(context.Background(), "repo-123")

    assert.Nil(t, repo)
    assert.ErrorIs(t, err, ErrRepoNotFound)
}

func TestRepoService_CreateRepo_ValidationError(t *testing.T) {
    svc := NewRepoService(nil)
    req := CreateRepoRequest{Name: ""}  // невалидный

    repo, err := svc.CreateRepo(context.Background(), req)

    assert.Nil(t, repo)
    var valErr *ValidationError
    assert.True(t, errors.As(err, &valErr))
    assert.Equal(t, "name", valErr.Field)
}

Задание 6: Recovery middleware

Реализуйте middleware для перехвата panic:

go
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Error("panic recovered",
                    "panic", rec,
                    "stack", string(debug.Stack()),
                    "request_id", getRequestID(r),
                )
                writeJSON(w, http.StatusInternalServerError, ErrorResponse{
                    Code:    "INTERNAL",
                    Message: "internal server error",
                })
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Quiz "Проверь себя"

1. Когда использовать sentinel errors, а когда error types?
2. Что вернёт этот код?
go
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("get user: %w", ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
3. Где должно происходить логирование ошибок?
4. Является ли nil error всегда успехом?
5. Что делает %w в fmt.Errorf?
6. Как правильно обернуть ошибку, сохранив контекст?
7. Какие утверждения про panic и recover верны? — выберите все подходящие
8. Что произойдёт при panic в горутине без recover?
9. Чем errors.Is() отличается от errors.As()?

✅ Итоги модуля

Студент должен:

  • Понимать тип error и идиоматичные паттерны
  • Оборачивать ошибки с сохранением контекста через %w
  • Использовать errors.Is() и errors.As() правильно
  • Проектировать sentinel errors и кастомные error types
  • Понимать когда использовать panic/recover
  • Реализовать recovery middleware
  • Написать тесты с проверкой ошибок
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 5

Следующий модуль: Модуль 5. Пакеты и организация проекта - дизайн пакетов, internal, clean folder structure. Привязка к структуре internal/ шаблона.