В данной статье представлен авторский подход к унификации и централизации механизма обработки ошибок в HTTP-обработчиках веб-сервисов, разработанных на языке Go. Статья подробно рассматривает ограничения традиционных методов обработки ошибок, ведущие к дублированию кода и снижению поддерживаемости. Предлагается новый архитектурный паттерн, включающий использование специализированной сигнатуры функций-обработчиков, кастомного типа ошибки HTTPError для инкапсуляции статуса ответа, сообщения для клиента и внутренней ошибки для логирования, а также Middleware-адаптера для интеграции с фреймворками net/http и Gin. Данный подход демонстрирует повышение читаемости кода, упрощение отладки и обеспечение консистентности ответов API, что представляет собой значимый вклад в практику разработки бэкенд-сервисов на Go.

Если вам интересен процесс и вы хотите следить за дальнейшими материалами, буду признателен за подписку на мой телеграм-канал. Там я публикую полезныe материалы по разработке, разборы сложных концепций, советы как быть продуктивным и конечно же отборные мемы: https://t.me/nullPointerDotEXE.

Поиск оптимального решения для обработки ошибок

В процессе разработки многочисленных бэкенд систем на языке Go, я неоднократно сталкивался с проблемой эффективной и консистентной обработки ошибок в HTTP-обработчиках (хендлерах). Стандартный подход зачастую приводит к дублированию кода проверки ошибок и формирования HTTP-ответов, что усложняет поддержку и развитие проекта. Глубокий анализ существующих решений, как в русскоязычном, так и в англоязычном сегментах интернета, показал отсутствие исчерпывающих руководств, которые бы предлагали комплексный и элегантный способ решения этой задачи. Хотя отдельные идеи встречались, они не покрывали всех нюансов или не предлагали универсального механизма.

Эта ситуация побудила меня к разработке собственного подхода, которым я и хочу поделиться. Основная цель — представить структурированный способ управления ошибками, который, по моему убеждению, может существенно улучшить качество и скорость разработки веб-приложений на Go. И пусть данный подход возможно не новшество в мире IT, поделиться я им обязан.

Дублирование кода и неконсистентность обработки ошибок

Традиционная обработка ошибок в Go-хендлерах часто выглядит следующим образом:

func (h *MyHandler) SomeBusinessLogicHandler(w http.ResponseWriter, r *http.Request) {
    data, err := h.service.GetData(r.Context(), r.URL.Query().Get("id"))
    if err != nil {
        if errors.Is(err, service.ErrNotFound) {
            http.Error(w, "Resource not found", http.StatusNotFound)
            log.Printf("Error fetching data: %v", err) // Логирование внутренней ошибки
            return
        }
        // ... другие специфичные проверки ошибок ...
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        log.Printf("Unhandled error fetching data: %v", err)
        return
    }
    // Успешная логика, отправка данных
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(data)
}

Такой подход имеет ряд недостатков:

  1. Дублирование кода: Логика проверки err != nil, логирования и отправки HTTP-ответа повторяется в каждом хендлере.

  2. Неконсистентность: Без строгой дисциплины формат сообщений об ошибках и используемые HTTP-статусы могут варьироваться от хендлера к хендлеру.

  3. Смешение ответственностей: Хендлер занимается как бизнес-логикой, так и деталями HTTP-протокола (формирование ответа об ошибке).

  4. Затрудненная отладка: Часто разработчики логируют то же сообщение, что отправляется клиенту, что не дает полной картины при анализе логов (например, "Resource not found" без указания, какой именно ресурс).

Централизация через Middleware и кастомный тип ошибки

Ключевая идея моего решения заключается в изменении сигнатуры хендлера таким образом, чтобы он мог возвращать ошибку, а специальный Middleware перехватывал бы эту ошибку и централизованно преобразовывал ее в HTTP-ответ.

1. Новая сигнатура обработчика и кастомный тип HandlerFuncWithError

Вместо стандартной func(w http.ResponseWriter, r *http.Request) предлагается использовать сигнатуру, возвращающую error :

type HandlerFuncWithError func(w http.ResponseWriter, r *http.Request) error

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

2. Структура HTTPError для детализированных ошибок

Для того чтобы передать больше информации об ошибке (HTTP-статус, сообщение для клиента, внутренняя ошибка для логирования), я ввел кастомную структуру HTTPError:

type HTTPError struct {
    Code       int    // HTTP статус код, который будет отправлен клиенту
    Message    string // Сообщение, которое будет отправлено клиенту в теле ответа
    InnerError error  // Оригинальная ошибка, для внутреннего логирования и отладки (не для клиента)
}

//конструктор, дабы удобно возвращать ошибку
func NewHTTPError(code int, message string, inner error) *HTTPError { 
    return &HTTPError{
        Code:       code,
        Message:    message,
        InnerError: inner,
    }
}

Нюансы структуры HTTPError:

  • Code: Явно указывает HTTP-статус, который должен быть возвращен клиенту. Это устраняет неоднозначность.

  • Message: Сообщение, безопасное для отображения клиенту. Оно может быть общим (например, "Not Found", "Invalid Input"), чтобы не раскрывать детали реализации.

  • InnerError: Здесь инкапсулируется исходная ошибка из сервисного слоя, базы данных и т.д. Эта ошибка никогда не должна показываться клиенту, но обязательно должна логироваться для разработчиков. Это критически важно для отладки: если Message — "An error occurred", то InnerError может содержать "database connection timeout" или "failed to parse user ID 'abc'".

Для возврата кастомных ошибок реализуем интерфейс-заглушку Error:

func (e *HTTPError) Error() string {
	return e.Message
}

Метод Error() реализует стандартный интерфейс. Его основная цель — удовлетворить интерфейс error. Внутри Wrap мы не полагаемся на результат этого метода для формирования ответа клиенту или для логирования внутренней ошибки, а используем поля Message и InnerError напрямую. Это позволяет более гранулярно управлять информацией.

3. Middleware-адаптер для обработки ошибок

Этот компонент является сердцем предложенного паттерна. Он оборачивает наш хендлер с новой сигнатурой и преобразует его в стандартный тип, понятный HTTP-фреймворку. При этом он перехватывает и обрабатывает возвращенную ошибку.

func WrapNetHTTP(endpoint HandlerFuncWithError) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := endpoint(w, r); err != nil {
            var httpErr *HTTPError
            if errors.As(err, &httpErr) {
                // Если это наша кастомная HTTPError
                if httpErr.InnerError != nil {
                    log.Printf("Client Message: %s, Internal Error: %s. Status Code: %d", httpErr.Message, httpErr.InnerError, httpErr.Code)
                } else {
                    log.Printf("HTTP error: %d %s", httpErr.Code, httpErr.Message)
                }
                w.Header().Set("Content-Type", "application/json; charset=utf-8")
                w.WriteHeader(httpErr.Code)
                json.NewEncoder(w).Encode(map[string]string{"error": httpErr.Message})
            } else {
                // Если это другая, непредвиденная ошибка
                log.Println("Internal server error:", err)
                w.Header().Set("Content-Type", "application/json; charset=utf-8")
                w.WriteHeader(http.StatusInternalServerError)
                json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error"})
            }
        }
    }
} 

Логика работы WrapNetHTTP:

  1. Выполняет переданный endpoint.

  2. Если endpoint возвращает ошибку (err != nil):

    • Используя errors.As, проверяется, является ли возвращенная ошибка экземпляром *HTTPError.

    • Если да, то логируется InnerError (если оно есть) для детальной отладки и Message для информации о том, что увидел клиент. Клиенту отправляется JSON с Message и статус-кодом Code.

    • Если это не *HTTPError, то ошибка считается непредвиденной внутренней ошибкой сервера. Логируется полная ошибка, а клиенту отправляется стандартное сообщение "Internal Server Error" со статус-кодом 500.

4. Пример использования для net/http

Ниже представлен минимальный, но полнофункциональный пример, демонстрирующий, как описанный архитектурный подход реализуется на практике. Обработка ошибок осуществляется единообразно, благодаря использованию обёртки WrapNetHTTP, что устраняет дублирование кода и обеспечивает высокую читаемость.

//простой пример 
//предполагается, что сервисной слой может вернуть ошибку
func (h *handler) signUp(w http.ResponseWriter, r *http.Request) error {
	var authData models.FirstAuth
	if err := json.NewDecoder(r.Body).Decode(&authData); err != nil {
		return NewHTTPError(http.StatusBadRequest, "Invalid request body", err)
	}

	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()

	if err := h.service.Auth.SignUp(ctx, &authData); err != nil {
		return NewHTTPError(http.StatusBadRequest, "Failed to create user", err)
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"message": "User created successfully"})
	return nil
}

func main() {
	repo := repository.NewRepository()
	service := service.NewService(repo)
	h := handler.NewHandler(service)

	mux := http.NewServeMux()
  //вот так происходит отлов ошибки. Это ключевое отличие
	mux.Handle("/auth/sign-up", httperror.WrapNetHTTP(h.signUp)) 

	log.Println("Starting server on :8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatal(err)
	}
}

Пояснение:

  • Объекты репозитория и сервисного слоя инициализируются традиционным способом и внедряются в обработчики.

  • Маршрут /auth/sign-up регистрируется с использованием адаптера WrapNetHTTP, который:

    • Оборачивает HandlerFuncWithError,

    • Интерпретирует возвращённую ошибку,

    • Автоматически формирует корректный HTTP-ответ и логирует внутренние ошибки, если таковые имеются.

  • В случае сбоя запуска сервера, происходит фатальное логирование.

5. Реализация для gin

Cтруктура, интерфейс и т.д. остаются неизменными, но меняется сигнатура на:

type HandlerFuncWithGinError func(c *gin.Context) error

И сам middleware:

func WrapGin(endpoint HandlerFuncWithGinError) gin.HandlerFunc {
    return func(c *gin.Context) {
        if err := endpoint(c); err != nil {
            var httpErr *HTTPError 
            if errors.As(err, &httpErr) {
                if httpErr.InnerError != nil {
                    log.Printf("%s: %s. Status code: %d", httpErr.Message, httpErr.InnerError, httpErr.Code)
                } else {
                    log.Printf("http error: %d %s", httpErr.Code, httpErr.Message)
                }
                c.JSON(httpErr.Code, gin.H{"error": httpErr.Message})
            } else {
                log.Println("internal error:", err)
                c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
            }
            c.Abort() // Важно для Gin, чтобы прервать цепочку обработчиков
        }
    }
}

Данный middleware выполняет тоже самое, что и ранее представленный выше.

Пример использования с Gin:

//простой пример 
//предполагается, что сервисной слой может вернуть ошибку
//аналогично и функция signIn
func (h *handler) signUp(c *gin.Context) error {
	var authData models.FirstAuth
	if err := c.ShouldBindJSON(&authData); err != nil {
        return NewHTTPError(http.StatusBadRequest, "Invalid request body", err)
    }

	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
	defer cancel()

	if err := h.service.Auth.SignUp(ctx, &authData); err != nil {
		return NewHTTPError(http.StatusBadRequest, "failed to create user", err)
	}

	c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"})
	return nil
}

func main() {
	r := gin.Default()
  
    repo := repository.NewRepository()
    service := service.NewService(repo)
 	h := handler.NewHandler(service)


    auth := r.Group("/auth")
 	{
 		auth.POST("/sign-in", WrapGin(h.signIn)) // Используем WrapGin
 		auth.POST("/sign-up", WrapGin(h.signUp)) // Используем WrapGin
 	}

 	log.Println("Starting server on :8081")
 	r.Run(":8081")
}

Как видно, реализация для Gin очень похожа на net/http в своей концепции, отличаясь лишь спецификой API фреймворка (контекст gin.Context, методы c.JSON, c.Abort()).

6. Обсуждение нюансов и преимуществ

Преимущества подхода:

  • Централизация логики: Вся логика обработки ошибок, включая логирование и формирование ответа, сосредоточена в одном месте (middleware Wrap).

  • Улучшение читаемости и снижение дублирования: Код обработчиков становится чище, так как из него уходит повторяющаяся логика обработки ошибок. Разработчики концентрируются на бизнес-логике.

  • Консистентность ответов: Гарантируется единообразие формата ошибок, отправляемых клиенту.

  • Гибкость логирования: Разделение Message и InnerError позволяет предоставлять пользователю лаконичные сообщения, а разработчику — полную информацию для отладки.

  • Упрощение поддержки: Изменение формата ответа или стратегии логирования требует модификации только middleware-адаптера.

Взаимодействие с другими middleware:

В контексте архитектуры web-приложений на Go , middleware представляет собой промежуточный слой, применяемый к цепочке обработки запроса. Его основная задача — модификация запроса и/или ответа, выполнение вспомогательных задач (логирование, аутентификация, CORS, rate limiting и пр.), либо принудительное прерывание дальнейшего выполнения цепочки хендлеров.

Ключевое правило: middleware не должен возвращать ошибку. Возврат error из middleware нарушает саму концепцию middleware как инфраструктурного слоя, обслуживающего запрос, но не принимающего окончательное решение о его обработке. Middleware, возвращающий ошибку, утрачивает нейтральность и начинает выполнять функции контроллера, т.е. фактически становится pre-handler'ом — обработчиком, который запускается до основной логики маршрута и формирует финальный HTTP-ответ. Поэтому ранее показанная централизованная обработка ошибок должна использоваться только на уровне конечных хендлеров, а не в промежуточных слоях.

Заключение

Централизация обработки ошибок является важным аспектом разработки качественного программного обеспечения. В этой статье я поделился своим опытом и представил решение, которое позволяет эффективно управлять ошибками в HTTP-обработчиках на Go. Использование кастомного типа HTTPError в сочетании с middleware-адаптером для обработчиков, возвращающих ошибки, значительно улучшает структуру кода, его читаемость и сопровождаемость. Примеры для net/http и Gin демонстрируют универсальность и простоту интеграции подхода. Я убежден, что предложенная методика может быть успешно применена во множестве проектов, принося ощутимую пользу разработчикам и повышая общую отказоустойчивость создаваемых ими систем. Это решение родилось из практической необходимости и, надеюсь, окажется ценным вкладом для Go-сообщества. Если вы встречали что-то похожее, то обязательно поделитесь этим в комментариях. Жду вашего фитбека.

Комментарии (5)


  1. F0rzend
    29.05.2025 19:58

    В целом неплохо. Но для обработки ошибок в http есть 7807, можно реализовать его структуру.

    А ещё, было бы прикольно сделать ошибки доменные с доменными кодами и сообщениями и в мидлвари коды домена мапить на коды http или другого интерфейса (grpc, etc)


  1. ErgoZru
    29.05.2025 19:58

    Неплохой вариант, за статью лайк. В целом давно такое использую у себя.

    От себя добавлю:

    Ответ для internal error я бы вынес в константу или в байт массиве и его бы отдавал наружу. Там сработает оптимизатор при сборке. (На сколько я помню).

    Для типовых ошибок лучше завести отдельные методы, в которых уже будет занесено и коды и сообщения для пользователя. Это позволит в структуре хранить и обрабатывать коды не только для http и при этом имея отдельные методы для ошибок это снизит количество случаев где надо помнить какие коды для какой ошибки, упростит написание документации, больше переисполтзования и тд. Все ошибки могут быть реализованы в отдельном пакете и прикрутить авто-документирование. Это крайне удобно по итогу (особенно когда 100500 ошибок в большом проекте и нужно вести их в доке). Ну и для кастомных случаев остается публичный метод с передачей параметров в функцию. Либо сделать структуру приватной и сделать обращение к ней более строгое. В общем вариантов как улучшить всегда есть.

    И да, я тоже в свое время ломал голову как адекватно прикрутить проброс ошибок.


  1. vkomp
    29.05.2025 19:58

    Обработка ошибки в враппере - интуитивно понятный подход, я тоже так делаю, хоть и через структуру в контексте - кроме 200 бывает 204, и обрабатываю случай, когда закомментил логику в обработчике. Тогда выбрасываю 418.
    А вот добавление ошибки к сигнатуре `func (w,r)` - тупиковый путь, ИМХО. Хотя бы потому, что сильно ломается совместимость, и появляются новые костыли. И мне очень нравится оригинальная сигнатура без ошибки - она СИЛЬНО отличает код http-обработчика от обычного модуля. Подробнее писал здесь: https://habr.com/ru/articles/901938/
    Вкратце: обработчику доступны разные http-коды, бизнес-логика знает только error. И выходит, что какой-то слой должен отделять мух от котлет - мне зашло оставить в обработчике, теперь "умный обработчик" знает часть логики (статья об этом).
    Были возражения типа "этот слой не для этого". Здесь наверно надо определить термины. Выделяю http-слой, который реально транспорт и легко заменяется шиной данных. А есть http-слой, который обслуживает web-клиент со своей логикой и ресурсами, и который никогда не будет принимать данные из других протоколов. Это две большие разницы!


  1. lazy_val
    29.05.2025 19:58

    Не совсем по теме, но:

    func (h *handler) signUp(w http.ResponseWriter, r *http.Request) error {
        ...
        ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
        ...
    }
    
    func (h *handler) signUp(c *gin.Context) error {
        ...
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        ...
    }
    

    Я ведь правильно понимаю что во втором случае тоже контекст запроса должен в сервисный слой передаваться?

    ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
    


    1. OyminiRole1776 Автор
      29.05.2025 19:58

      Да, правильно передавать именно c.Request.Context()