Модуль 4. Обработка ошибок
Цели модуля
После модуля студент сможет:
- Понимать тип error и идиоматичные паттерны обработки
- Оборачивать ошибки с сохранением контекста через %w
- Использовать errors.Is() и errors.As() для проверки ошибок
- Проектировать sentinel errors и кастомные error types
- Понимать когда использовать panic/recover
- Принимать осознанные решения: error vs panic
- Проектировать стратегию ошибок для сервиса
4.1 Тип error и паттерны
Что такое error
В Go error - это встроенный интерфейс с одним методом:
gotype error interface { Error() string }
Любой тип, реализующий метод Error() string, удовлетворяет интерфейсу error.
Базовый паттерн: проверка ошибки
Go не имеет исключений. Ошибки возвращаются как значения и проверяются явно.
goresult, 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 последним возвращаемым значением
gofunc 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 }
Формат оборачивания
gofmt.Errorf("операция: %w", err)
Правила:
- Начинайте с описания операции (что делали)
- Используйте %w для сохранения цепочки
- Не дублируйте информацию из оригинальной ошибки
- Добавляйте контекст (id, параметры)
Пример цепочки ошибок
textHTTP 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 ошибкой. Работает через всю цепочку обёрток.
govar 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 извлекает ошибку конкретного типа из цепочки.
gotype 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
govar ( 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
gofunc (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 - предопределённые переменные ошибок. Используются для простых проверок.
govar ( 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 - кастомные типы ошибок с дополнительными полями. Используются когда нужен контекст.
gotype 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 ошибки с кодами и сообщениями
Сравнение
| Критерий | Sentinel | Error type |
|---|---|---|
| Простота | Очень простая | Сложнее |
| Контекст | Нет | Есть (поля) |
| Проверка | errors.Is | errors.As |
| Когда | Простые проверки | Нужен контекст |
| Пример | ErrNotFound | ValidationError{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 ошибок с кодами:
gotype 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:
gotype 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 не пойман, программа падает.
gofunc 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.
gofunc 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.
gogo 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 |
Привязка к шаблону проекта
Структура ошибок в шаблоне
textinternal/service/ ├── errors.go # Sentinel errors для домена ├── repo_service.go # Использование ошибок ├── user_service.go └── ... internal/usecase/ ├── create_repo_usecase.go # Проброс ошибок с контекстом ├── get_repo_usecase.go └── ...
Что изучаем на примере шаблона
- Sentinel errors определены в internal/service/errors.go
- Сервисы оборачивают ошибки с контекстом операции
- Use cases пробрасывают ошибки с дополнительным контекстом
- Handlers обрабатывают ошибки на границе (errors.Is)
- Логирование происходит только на границе
Задание: изучите internal/service/
- Какие sentinel errors определены?
- Как ошибки оборачиваются в сервисах?
- Где происходит логирование ошибок?
- Как handler определяет тип ошибки (errors.Is)?
💱 Компромиссы
%wvs%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:
gopackage 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
Создайте кастомную ошибку валидации:
gopackage 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 с правильным оборачиванием:
gopackage 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 с обработкой ошибок:
gofunc (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: Тесты для ошибок
Напишите тесты с проверкой ошибок:
gofunc 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:
gofunc 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 "Проверь себя"
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("get user: %w", ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))%w в fmt.Errorf?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/ шаблона.