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 — не баг дизайна языка, а явное декларирование ветки выполнения, где разработчик обязан принять решение: обработать локально, обернуть в контекст или пробросить наверх.

Go
// Идиоматично: явная проверка, ранний возврат
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.
Go
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-сервисах ошибки делятся на категории: доменные, инфраструктурные и системные. Сервис не должен возвращать инфраструктурные ошибки СУБД или сетевые сбои напрямую. Он маппит их в доменные ошибки, сохраняя контекст.

Go
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.

Go
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-подход требует больше кода, но дает полный контроль над восстановлением после сбоя и предотвращает скрытые переходы в неизвестное состояние, характерные для исключений.


Итог

  1. Ошибки в Go — это значения. Обрабатывайте их явно и локально, где это возможно.
  2. Используйте errors.Is/As и %w для построения предсказуемой цепочки контекста.
  3. Маппите доменные ошибки в HTTP-коды только на границе сервера.
  4. Избегайте глубокого оборачивания и создания ошибок в hot-path без необходимости.
  5. Возвращайте явный nil типа error, а не указатель на nil-структуру.
  6. Логгируйте только непредвиденные ошибки на уровне инфраструктуры, бизнес-ошибки отдавайте клиенту структурированно.

Следующая статья: 16. Healthcheck и readiness probe