15. Error handling в сервисах
В хорошей программе код обработки ошибок занимает больше места, чем так называемый «счастливый путь» (happy path). В реальных распределенных системах всё, что может пойти не так, обязательно пойдет не так: удаленный сервер закроет соединение, диск переполнится, база данных исчерпает пул подключений, а клиент пришлет поврежденный JSON.
Во многих языках программирования ошибки прячут за механизмом исключений (try-catch-finally). Исключения создают иллюзию чистоты кода, но на деле работают как невидимый оператор GOTO, разрушая предсказуемость исполнения программы и заставляя процессор дорого платить за раскрутку стека.
Создатели Go Роб Пайк и Кен Томпсон вернулись к истокам Unix и языка C: ошибки — это обычные значения (Errors are values). Они не прерывают ход времени, не ломают конвейер процессора и обрабатываются явно там, где они возникли.
flowchart TD
A["Handler вызывает Service"] --> B["Проверка ошибки"]
B -->|"Нет"| C["200 OK + JSON"]
B -->|"Да"| D["Типизация ошибки"]
D -->|"ValidationError"| E["400 Bad Request"]
D -->|"ErrNotFound"| F["404 Not Found"]
D -->|"Unknown Infra"| G["500 Internal Server Error"]
classDef ok fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
classDef check fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef err fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
class C ok;
class A,B,D check;
class E,F,G err;
Философия Error Handling в Go
В отличие от Java, C# или PHP, где ошибки управляются через механизм исключений и неявный unwind стека, Go трактует ошибки как обычные значения. Это фундаментальное архитектурное решение, которое меняет подход к потоку управления и отказоустойчивости.
Ошибка — это не аномальное прерывание выполнения, а ожидаемый результат операции, который несет контекст о том, почему действие не удалось. Идиома if err != nil — не баг дизайна языка, а явное декларирование ветки выполнения, где разработчик обязан принять решение: обработать локально, обернуть в контекст или пробросить наверх.
// Идиоматично: явная проверка, ранний возврат
func GetOrder(ctx context.Context, id int64) (*Order, error) {
order, err := db.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("find order %d: %w", id, err)
}
return order, nil
}
1. Стандартные механизмы и ошибки как значения
Пакет errors и fmt предоставляют инструменты для классификации, сравнения и обогащения ошибок без потери оригинального типа. Современный Go требует использования обертки %w и методов errors.Is/As.
errors.Is(err, target): проверка цепочки оберток на равенство конкретному sentinel-ошибку.errors.As(err, target): безопасное приведение к кастомному типу ошибки в цепочке.%wвfmt.Errorf: оборачивание с сохранением оригинала для последующегоIs/As.
var ErrNotFound = errors.New("resource not found")
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string { return e.Msg }
func process(ctx context.Context) error {
if err := fetch(ctx); err != nil {
if errors.Is(err, ErrNotFound) {
return ErrNotFound // Sentinel
}
var ve *ValidationError
if errors.As(err, &ve) {
return fmt.Errorf("validation failed on %s: %w", ve.Field, err)
}
return fmt.Errorf("fetch failed: %w", err)
}
return nil
}
2. Паттерн обработки в сервисном слое
В production-сервисах ошибки делятся на категории: доменные, инфраструктурные и системные. Сервис не должен возвращать инфраструктурные ошибки СУБД или сетевые сбои напрямую. Он маппит их в доменные ошибки, сохраняя контекст.
func (s *UserService) Create(ctx context.Context, req *CreateUserReq) error {
if err := s.validate(req); err != nil {
return fmt.Errorf("validate: %w", err)
}
if err := s.repo.Create(ctx, &User{Email: req.Email}); err != nil {
// Ошибку нарушения уникальности из репозитория превращаем в доменный бизнес-сигнал
if errors.Is(err, repo.ErrAlreadyExists) {
return fmt.Errorf("create user: %w", ErrDuplicateEmail)
}
return fmt.Errorf("repo create: %w", err)
}
return nil
}
3. Маппинг ошибок в HTTP-слое
Обработчик — это переводчик. Он преобразует доменные ошибки в HTTP-статусы и структурированные JSON-ответы. Бизнес-логика не должна знать о http.StatusBadRequest.
func writeError(w http.ResponseWriter, err error) {
var status int
var code string
var valErr *ValidationError
switch {
case errors.Is(err, ErrNotFound):
status = http.StatusNotFound
code = "NOT_FOUND"
case errors.Is(err, ErrDuplicate):
status = http.StatusConflict
code = "DUPLICATE"
case errors.As(err, &valErr):
status = http.StatusBadRequest
code = "VALIDATION_FAILED"
default:
status = http.StatusInternalServerError
code = "INTERNAL_ERROR"
log.Printf("unhandled error: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Code: code, Message: err.Error()})
}
4. Производительность и Mechanical Sympathy
Создание ошибки в Go — операция с измеримой стоимостью. errors.New() аллоцирует строку в куче. fmt.Errorf создает форматированную строку + структуру. В hot-path генерация тысяч ошибок в секунду вызывает резкий всплеск аллокаций и повышает нагрузку на GC.
Оптимизация:
- Используйте sentinel-ошибки (
var ErrX = errors.New("...")) для повторяемых состояний. Они инициализируются один раз при старте пакета в.rodataсегменте и живут вечно. - Избегайте
fmt.Errorfв циклах валидации. Возвращайте код или булево значение, конвертируйте в текст только на границе ответа. panicиrecover— не замена error handling. Создание stack trace черезruntime.Callersаллоцирует память и требует unwind. Используйте только для фатальных состояний (битая конфигурация, невозможность подключиться к критической БД).
5. Сравнение с другими языками
| Аспект | Java / C# / PHP | Go |
|---|---|---|
| Поток управления | Неявный, catch прерывает стек |
Явный, if err != nil ветвит выполнение |
| Контекст ошибки | Stack trace автоматически | Вручную через %w или кастомные поля |
| Производительность | throw дорого, требует unwind стека |
Дешево при err == nil. Аллокация только при создании |
| Тестируемость | Мокирование исключений сложно | Ошибки — данные, легко мокать и проверять |
Go-подход требует больше кода, но дает полный контроль над восстановлением после сбоя и предотвращает скрытые переходы в неизвестное состояние, характерные для исключений.
Итог
- Ошибки в Go — это значения. Обрабатывайте их явно и локально, где это возможно.
- Используйте
errors.Is/Asи%wдля построения предсказуемой цепочки контекста. - Маппите доменные ошибки в HTTP-коды только на границе сервера.
- Избегайте глубокого оборачивания и создания ошибок в hot-path без необходимости.
- Возвращайте явный
nilтипаerror, а не указатель на nil-структуру. - Логгируйте только непредвиденные ошибки на уровне инфраструктуры, бизнес-ошибки отдавайте клиенту структурированно.
Следующая статья: 16. Healthcheck и readiness probe