---
id: 982178b2b9c8c091685bdc7fbf942c43
author: snowmerak
title: Более удобная обработка HTTP-ошибок + RFC7807
description: 'Надоело возиться с обработкой ошибок в Go API? Попробуйте RFC 7807: это более простой и понятный способ.'
language: ru
date: 2025-05-19T20:56:37.945722346Z
path: /blog/posts/handling-http-errors-more-conveniently-+-rfc7807-z862df968
category: api
---

## Обзор

При создании HTTP API на языке Go самая утомительная часть это обработка ошибок. Типичный пример выглядит так.

```go
func(w http.ResponseWriter, r *http.Request) {
    err := doSomething()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        log.Printf("error occurred: %v", err)
        return
    }
    // ...
}
```

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

1. Возврат подходящего кода ошибки
2. Большой объём кода для записи логов
3. Передача понятного сообщения об ошибке

## Основная часть

### Возврат подходящего кода ошибки

Пункт 1, возврат подходящего кода ошибки, действительно является моей личной претензией. Опытный разработчик всегда подберёт нужный код и аккуратно его укажет, но и мне, и другим начинающим разработчикам с ростом сложности логики и количества обработчиков становится трудно последовательно использовать подходящие коды ошибок. Существует несколько способов решить эту проблему, самый распространённый из которых: заранее спроектировать поток логики API и затем написать код, который возвращает соответствующую ошибку. ~~Так и делайте~~

Однако этот подход не выглядит оптимальным для человека-разработчика, которому помогают IDE (или Language Server). Кроме того, поскольку REST API сам по себе максимально опирается на смысл, заключённый в кодах ошибок, можно предложить другой способ. Создадим новую реализацию интерфейса ошибки (`error`) с именем `HttpError`, которая хранит `StatusCode` и `Message`. А затем предоставим такие вспомогательные функции:

```go
err := httperror.BadRequest("wrong format")
```

Вспомогательная функция `BadRequest` вернёт `HttpError`, в котором `StatusCode` равен 400, а `Message` установлен из переданного аргумента. Помимо неё, разумеется, можно будет находить и добавлять вспомогательные функции `NotImplement`, `ServiceUnavailable`, `Unauthorized`, `PaymentRequired` и другие через автодополнение. Это быстрее, чем каждый раз сверяться с заранее подготовленной таблицей, и надёжнее, чем каждый раз вводить коды ошибок числами. ~~Вы скажете, что всё есть в константах `http.StatusCode`? Тсс~~

### Большой объём кода для записи логов

При возникновении ошибки, конечно, нужно вести журнал. При записи логов о вызове API и о том, завершился ли запрос успешно или с ошибкой, если логировать каждую ожидаемую точку завершения с самого начала, объём кода сильно возрастает. Если обернуть сам обработчик, логирование можно будет вести централизованно.

Ниже приведён пример оборачивания роутера `chi`.

```go
package chiwrap

import (
	"errors"
	"net/http"

	"github.com/go-chi/chi/v5"

	"github.com/gosuda/httpwrap/httperror"
)

type Router struct {
	router      chi.Router
	errCallback func(err error)
}

func NewRouter(errCallback func(err error)) *Router {
	if errCallback == nil {
		errCallback = func(err error) {}
	}
	return &Router{
		router:      chi.NewRouter(),
		errCallback: errCallback,
	}
}

type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error

func (r *Router) Get(pattern string, handler HandlerFunc) {
	r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
		if err := handler(writer, request); err != nil {
			he := &httperror.HttpError{}
			switch errors.As(err, &he) {
			case true:
				http.Error(writer, he.Message, he.Code)
			case false:
				http.Error(writer, err.Error(), http.StatusInternalServerError)
			}
			r.errCallback(err)
		}
	})
}
```

Структура роутера содержит внутри `chi.Router`, поэтому функциональность `chi.Router` используется без изменений. В методе `Get` проверяется, возвращена ли структура `HttpError`, которую создают вспомогательные функции, предложенные выше, и в случае успеха возвращается соответствующий ответ. Если же это обычная `error`, она единообразно передаётся в функцию обратного вызова для ошибок. Эта функция принимается через конструктор.

Ниже приведён код, написанный с использованием этого пакета.

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequest("name is required")
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

Как вам такой вариант? Если просто создавать `HttpError` через вспомогательные функции и возвращать их, то на верхнем уровне можно отвечать клиенту подходящим кодом и сообщением, а для каждого сервиса регистрировать обратный вызов, который ведёт соответствующие логи. При необходимости можно расширить подход, например, используя `RequestID`, и вести более подробное логирование.

### Передача понятного сообщения об ошибке

Для этого существует документ RFC7807. RFC7807 в основном определяет и использует следующие элементы.

- `type`: URI, идентифицирующий тип ошибки. Обычно это документ, описывающий ошибку.
- `title`: краткое однострочное описание ошибки.
- `status`: совпадает с HTTP Status Code.
- `detail`: подробное описание ошибки, понятное человеку.
- `instance`: URI, в котором возникла ошибка. Например, если ошибка возникла при `GET /user/info`, значением будет `/user/info`.
- `extensions`: дополнительные элементы для пояснения ошибки, представленные в виде JSON-объекта.
  - Например, для `BadRequest` может включаться пользовательский ввод.
  - Или для `TooManyRequest` может включаться время самого последнего запроса.

Чтобы упростить использование, создадим новый файл в пакете `httperror`, расположенном рядом с `HttpError`, определим структуру `RFC7807Error` и сделаем так, чтобы её можно было создавать через цепочку вызовов методов.

```go
func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
	return &RFC7807Error{
		Type:   "about:blank", // Значение по умолчанию согласно RFC7807
		Title:  title,
		Status: status,
		Detail: detail,
	}
}

func BadRequestProblem(detail string, title ...string) *RFC7807Error {
	t := "Bad Request"
	if len(title) > 0 && title[0] != "" {
		t = title[0]
	}
	return NewRFC7807Error(http.StatusBadRequest, t, detail)
}

func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
```

Значение `"about:blank"` для `Type` является значением по умолчанию. Оно означает, что страница отсутствует. Ниже приведён пример создания ошибки для неверного запроса.

```go
problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")

problem = problem.WithType("https://example.com/errors/validation")
         .WithInstance("/api/users/abc")
         .WithExtension("invalid_field", "user_id")
         .WithExtension("expected_format", "numeric")
```

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

```go
func (p *RFC7807Error) ToHttpError() *HttpError {
	jsonBytes, err := json.Marshal(p)
	if err != nil {
		// Если маршалинг завершился ошибкой, используем только detail
		return New(p.Status, p.Detail)
	}
	return New(p.Status, string(jsonBytes))
}
```

Если использовать его напрямую и изменить предыдущий пример, получится следующее.

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequestProblem("name is required", "Bad User Input").
                WithType("https://example.com/errors/validation").
                WithInstance("/api/echo").
                WithExtension("invalid_field", "name").
                WithExtension("expected_format", "string").
                WithExtension("actual_value", name).
                ToHttpError()
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

## Заключение

Если обрабатывать ошибки через централизованный роутер, можно снизить нагрузку, связанную с необходимостью каждый раз проверять код ошибки и формулировать подходящее сообщение. Кроме того, использование RFC7807 для передачи структурированных сообщений об ошибках помогает клиенту понимать ошибку и обрабатывать её. Так можно сделать обработку ошибок в HTTP API, написанных на языке Go, более простой и единообразной.

Код из этой статьи можно посмотреть в репозитории [gosuda/httpwrap](https://github.com/gosuda/httpwrap).
