Консистентная обработка ошибок в распределённых системах: от простого паника к надёжным слоям

Кто не сталкивался с загадочным 500 Internal Server Error в продакшене? Нелогичные отказы, недоступность критичных функций, оповещения Slack в 3 часа ночи — всё это может быть следством пробелов в обработке ошибок. Разработка надёжных систем распределённых систем требует вдумчивого подхода, особенно в обработке ошибок. Рассмотрим эволюцию полноценной стратегии обработки ошибок в backend-разработке.

Фундамент: Почему стандартная обработка недостаточна

Примитивный подход к обработке ошибок часто выглядит так:

go
app.POST("/users", func(c *gin.Context) {
    var user User
    if err := c.BindJSON(&user); err != nil {
        c.JSON(400, gin.H{"error": "invalid email"})
        return
    }
    
    if err := db.Create(&user).Error; err != nil {
        c.JSON(500, gin.H{"error": "something went wrong"})
        return
    }
    
    c.JSON(201, user)
})

При всей кажущейся понятности, здесь есть четыре фундаментальные проблемы:

  1. Утечка деталей ошибки (SQL-ошибки могут экспортироваться клиенту)
  2. Отсутствует контекст вызова (какая операция вызвала сбой?)
  3. Нет категоризации ошибок
  4. Невозможна централизованная обработка

Стратегия: многослойный подход

Создадим файл error_utils.go с компонентами для обработки ошибок:

go
package errors

type ErrorCode int

const (
    ECONFLICT ErrorCode = 409  // Конфликт данных
    EINTERNAL ErrorCode = 500  // Внутренняя ошибка
    ENOTFOUND ErrorCode = 404  // Не найдено
    EINVALID  ErrorCode = 400  // Невалидные данные
)

type AppError struct {
    Code      ErrorCode
    Message   string
    Operation string
    Err       error
    Metadata  map[string]any
}

func Error(op string, err error, code ErrorCode, msg string) *AppError {
    return &AppError{
        Operation: op,
        Err:       err,
        Code:      code,
        Message:   msg,
    }
}

func Wrap(op string, err error) *AppError {
    if appErr, ok := err.(*AppError); ok {
        return appErr
    }
    return &AppError{
        Operation: op,
        Err:       err,
        Code:      EINTERNAL,
    }
}

func (e *AppError) Error() string {
    return fmt.Sprintf("%s [%s]: %v (code %d)", 
        e.Operation, e.Message, e.Err, e.Code)
}

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

Практика: применение в бизнес-логике

Используем структурированную обработку в сервисном слое:

go
package user

func (s *Service) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
    const op = "user.Service.CreateUser"

    if err := validateUserInput(input); err != nil {
        return nil, errors.Error(op, err, errors.EINVALID, "invalid user data")
    }

    user, err := toUserEntity(input)
    if err != nil {
        return nil, errors.Error(op, err, errors.EINTERNAL, "conversion error")
    }

    if err := s.repo.CreateUser(ctx, user); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, errors.Error(op, err, errors.ENOTFOUND, "user not found")
        }
        if strings.Contains(err.Error(), "duplicate") {
            return nil, errors.Error(op, err, errors.ECONFLICT, "email already exists")
        }
        return nil, errors.Wrap(op, err)
    }

    return entityToDomain(user), nil
}

Централизованное обрамление: middleware как PPE

Обработчик Gin/Gonic для ошибок:

go
func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next() // Выполняем запрос

        if len(c.Errors) > 0 {
            err := c.Errors.Last().Err
            appErr, ok := err.(*errors.AppError)
            if !ok {
                appErr = &errors.AppError{
                    Code:    errors.EINTERNAL,
                    Message: "Unknown error",
                    Err:     err,
                }
            }

            // Логируем детали для себя
            log.Printf("HTTP_ERROR | %s | %v\n", appErr.Operation, appErr.Err)

            // Клиентам даём очищенную информацию
            response := map[string]interface{}{
                "code":    appErr.Code,
                "message": appErr.Message,
            }
            
            status := http.StatusInternalServerError
            if appErr.Code == errors.EINVALID {
                status = http.StatusBadRequest
            }
            // ... остальные коды статуса

            c.JSON(status, response)
        }
    }
}

Реальный кейс: обработка потоковых данных

Для асинхронных обработчиков с Kafka или RabbitMQ требуется другой уровень обработки ошибок:

go
func (c *Consumer) HandleRegistrationEvent(event Event) error {
    const op = "kafka.HandleRegistrationEvent"
    
    payload, err := parseEvent(event.Payload)
    if err != nil {
        metrics.ErrorCount.WithLabelValues("parse").Inc()
        return errors.Error(op, err, errors.EINVALID, "invalid payload")
    }

    if err := c.userService.SendWelcomeEmail(payload.Email); err != nil {
        // Если не можем отправить письмо - логируем, но не прерываем
        log.Printf("Failed send email: %v", err)
        metrics.ErrorCount.WithLabelValues("email").Inc()
        
        // Не возвращаем ошибку - хотим избежать повтора события?
        return nil 
    }
    
    return nil
}

Прикладная ошибкоустойчивость: паттерны восстановления

  1. Повторные попытки с экспоненциальными откатами (retry with backoff):
go
func DoWithRetry(
    fn func() error,
    delays ...time.Duration,
) error {
    maxAttempts := len(delays)
    for attempt := 0; attempt < maxAttempts; attempt++ {
        if err := fn(); err == nil {
            return nil
        }
        time.Sleep(delays[attempt])
    }
    return ErrServiceUnavailable
}

// Использование
err := DoWithRetry(func() error {
    return paymentProvider.Charge(cardToken)
}, 
0, 500*time.Millisecond, 2*time.Second, 5*time.Second)
  1. Прерыватели (Circuit Breakers):
go
breaker := circuitbreaker.NewCircuitBreaker(0.95)
err := breaker.Execute(func() (interface{}, error) {
    return nil, paymentProvider.Charge(cardToken)
})
if err != nil {
    if circuitbreaker.IsOpen(err) {
        cacheFallbackPayment()
    }
    return err
}

Тестирование ошибок: делаем тесты значимыми

Не доверяйте коду обработки ошибок без тестирования:

go
func TestCreateUser_DuplicateEmail(t *testing.T) {
    // Случайное имя БД для изоляции теста
    db := testDB()
    s := NewService(db)
    
    input := &CreateUserInput{Email: "test@example.com"}
    _, err := s.CreateUser(context.Background(), input)
    require.NoError(t, err)
    
    // Создаем пользователя с тем же email
    _, err = s.CreateUser(context.Background(), input)
    require.Error(t, err)
    var appErr *errors.AppError
    require.ErrorAs(t, err, &appErr)
    assert.Equal(t, errors.ECONFLICT, appErr.Code)
    assert.Contains(t, appErr.Message, "already exists")
}

Эволюция: метрики и мониторинг

Любая система вылезающих ошибок требует мониторинга:

yaml
# Prometheus метрики
errors_total{operation="CreateUser", type="E_INVALID"} 5
errors_total{operation="ProcessPayment", type="E_INTERNAL"} 1

Отправляйте алерты на разные уровни ошибок через Grafana/Sentry.

Заключение

Проработка обработки ошибок требует структурирования по трём уровням:

  • Детальные, контекстные ошибки в бизнес-логике
  • Преобразование в клиентские ответы в транспорте
  • Долгосрочный мониторинг всех сбоев

Код с "просто ерроррейзер" превращается в систему, когда:

  • Ошибки классифицированы
  • Содержат операционный контекст
  • Сообщения клиенту и логи разделены
  • Автоматически собираются метрики
  • Тесты покрывают экстремальные случаи

Стоимость отказа от хорошей обработки ошибок — часы отладки и уязвимости безопасности. Цена её внедрения — пара часов архитекторских усилий.

Выигрыш: детерминировано обрабатываем любые проблемы в один клик, а не гуглим лог-файлы. Пользователи получают полезные сообщения, разработчики — содержательные трассировки, продакшн — стабильное ночное время.