В Go нет исключений, которые сами долетают до общего перехватчика: ошибка — обычное значение, и если обработчик её не вернул, она исчезает. Поэтому «единый обработчик» здесь не аннотация, а соглашение: обработчики возвращают error, и один адаптер превращает его в HTTP-ответ. Без этого соглашения в каждом обработчике живёт свой if err != nil { w.WriteHeader(500) }, формат ответа у каждого разработчика свой, а тест на ошибку пишется отдельно для каждого метода.
Проблема: разбор ошибок в каждом обработчике
Без общего места обработчик выглядит так:
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
o, err := h.orders.ByID(r.Context(), chi.URLParam(r, "id"))
if errors.Is(err, order.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, o)
}
Проблемы те же, что везде: соответствие «ошибка → статус» повторяется в каждом методе, тело ответа у http.Error текстовое, а у соседнего обработчика JSON, и ни одно из этих мест не знает про идентификатор трассировки.
Короткая формула: обработчик описывает успешный путь и возвращает ошибку; что с ней делать — сквозная ответственность одного адаптера.
Обработчик возвращает error
Стандартный http.HandlerFunc ничего не возвращает. Заводят свой тип и адаптер к стандартному:
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func Handle(h HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
httperr.Write(w, r, err)
}
}
}
Обработчик становится коротким:
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) error {
o, err := h.orders.ByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, o)
}
r.Get("/orders/{id}", Handle(h.Get))
Вся обработка ошибок ушла в httperr.Write — одну функцию на всё приложение. Она и есть единый обработчик.
Ошибка несёт вид, а не статус
Домен не должен знать про HTTP, но должен сказать, что за ошибка. Для этого у ошибки есть вид:
package apperr
type Kind int
const (
KindUnknown Kind = iota
KindInvalid
KindUnauthorized
KindForbidden
KindNotFound
KindConflict
KindRule
)
type Error struct {
Kind Kind
Code string
Msg string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return e.Code + ": " + e.Err.Error()
}
return e.Code + ": " + e.Msg
}
func (e *Error) Unwrap() error { return e.Err }
func NotFound(code, msg string) error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} }
func Rule(code, msg string) error { return &Error{Kind: KindRule, Code: code, Msg: msg} }
func Conflict(code, msg string) error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
Домен бросает apperr.NotFound("ORDER_NOT_FOUND", "заказ не найден"), и про 404 в этом месте ничего не сказано. Соответствие вида и статуса живёт в веб-слое:
| Вид ошибки | HTTP-статус |
|---|---|
KindInvalid (неверный формат полей) | 400 Bad Request |
KindUnauthorized | 401 Unauthorized |
KindForbidden | 403 Forbidden |
KindNotFound | 404 Not Found |
KindConflict | 409 Conflict |
KindRule (поля верны, правило нарушено) | 422 Unprocessable Content |
| всё остальное | 500 Internal Server Error |
httperr.Write: один центр
package httperr
func Write(w http.ResponseWriter, r *http.Request, err error) {
traceID := trace.SpanContextFromContext(r.Context()).TraceID().String()
var appErr *apperr.Error
if errors.As(err, &appErr) {
status := statusOf(appErr.Kind)
logExpected(r, appErr, status)
writeProblem(w, Problem{Status: status, Code: appErr.Code, Detail: appErr.Msg, TraceID: traceID})
return
}
if errors.Is(err, context.Canceled) {
return
}
slog.ErrorContext(r.Context(), "unhandled error", "err", err, "method", r.Method, "path", r.URL.Path)
writeProblem(w, Problem{
Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR",
Detail: "Попробуйте позже. Если повторяется, сообщите идентификатор запроса.", TraceID: traceID,
})
}
func statusOf(k apperr.Kind) int {
switch k {
case apperr.KindInvalid:
return http.StatusBadRequest
case apperr.KindUnauthorized:
return http.StatusUnauthorized
case apperr.KindForbidden:
return http.StatusForbidden
case apperr.KindNotFound:
return http.StatusNotFound
case apperr.KindConflict:
return http.StatusConflict
case apperr.KindRule:
return http.StatusUnprocessableEntity
default:
return http.StatusInternalServerError
}
}
errors.As находит *apperr.Error на любой глубине обёртки: сценарий может вернуть fmt.Errorf("cancel order %s: %w", id, err), и вид всё равно будет прочитан. Это и есть замена иерархии исключений: switch по виду узнаёт ошибку, завёрнутую сколько угодно раз, а ветка по умолчанию остаётся 500.
context.Canceled обрабатывается отдельно: клиент ушёл, писать ему ответ некому, а в журнал ошибок это не попадает. Problem — структура ответа по RFC 9457 с типом application/problem+json; её устройство разобрано в статье про ошибки REST API.
Где ошибки базы превращаются в доменные
pgx.ErrNoRows — не доменная ошибка, а подробность адаптера. Если пропустить её наверх, httperr.Write не узнает вид и отдаст 500 вместо 404. Перевод делают на границе репозитория:
func (r *Repo) ByID(ctx context.Context, id string) (Order, error) {
row, err := r.q.GetOrder(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return Order{}, apperr.NotFound("ORDER_NOT_FOUND", "заказ не найден")
}
if err != nil {
return Order{}, fmt.Errorf("get order %s: %w", id, err)
}
return toDomain(row), nil
}
То же с нарушением уникальности: *pgconn.PgError с кодом 23505 превращается в apperr.Conflict здесь, а не в обработчике. Правило: наверх из адаптера уходят либо доменные ошибки, либо обёрнутые технические, которые станут 500.
Чего обработчик не видит
Адаптер Handle ловит ошибки, возвращённые обработчиком. Всё, что случилось раньше или мимо, до него не доходит.
Middleware. Проверка токена работает до обработчика и сама пишет ответ. Если она пишет http.Error(w, "unauthorized", 401), в одном API живут две формы ошибок, и клиенты пишут две ветки разбора. Лечится тем же httperr.Write:
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := principalFrom(r)
if err != nil {
httperr.Write(w, r, apperr.Unauthorized("UNAUTHENTICATED", "нужен токен"))
return
}
next.ServeHTTP(w, r.WithContext(auth.WithPrincipal(r.Context(), p)))
})
}
Роутер. Неизвестный путь и неподдерживаемый метод отдаёт сам chi — текстом. Переопределяют один раз:
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
httperr.Write(w, r, apperr.NotFound("ROUTE_NOT_FOUND", "нет такого пути"))
})
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
httperr.Write(w, r, &apperr.Error{Kind: apperr.KindInvalid, Code: "METHOD_NOT_ALLOWED", Msg: "метод не поддерживается"})
})
Паника. В Go паника в обработчике без перехвата роняет соединение, а net/http печатает стек в журнал и продолжает работу сервера. middleware.Recoverer из chi перехватывает панику, но отвечает голым 500. Свой перехватчик пишет в вашем формате:
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
if p == http.ErrAbortHandler {
panic(p)
}
slog.ErrorContext(r.Context(), "panic", "panic", p, "stack", string(debug.Stack()))
httperr.Write(w, r, fmt.Errorf("panic: %v", p))
}
}()
next.ServeHTTP(w, r)
})
}
http.ErrAbortHandler пробрасывают дальше: это служебная паника, которой обработчик намеренно обрывает ответ, и её не надо превращать в 500.
Ошибки после начала ответа. Если обработчик уже вызвал WriteHeader и часть тела ушла, статус изменить нельзя — httperr.Write тут бессилен, в журнале появится superfluous response.WriteHeader call. Отсюда правило: сначала выполнить всё, что может упасть, и только потом писать ответ; writeJSON кодирует тело в буфер и пишет его одним вызовом.
Несколько обработчиков сразу
Один формат ошибок на сервис работает, пока сервис небольшой. У административного API или у старого API для партнёров формат может быть другим. В Go это не приоритеты, а композиция: адаптер параметризуется писателем, и у поддерева роутов свой:
func HandleWith(write func(http.ResponseWriter, *http.Request, error)) func(HandlerFunc) http.HandlerFunc {
return func(h HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
write(w, r, err)
}
}
}
}
public := HandleWith(httperr.Write)
legacy := HandleWith(legacyerr.Write)
r.Route("/api/v1", func(r chi.Router) { r.Get("/orders/{id}", public(h.Get)) })
r.Route("/partner", func(r chi.Router) { r.Get("/orders/{id}", legacy(h.Get)) })
Какой писатель сработает, видно по месту регистрации маршрута, и «мой обработчик не вызвался» здесь не бывает: порядка и приоритетов нет, есть один вызов на один маршрут.
Что клиенту, что в журнал
Клиент получает безопасный минимум: статус, машинный код, человеческое сообщение и идентификатор трассировки. Журнал получает всё остальное: цепочку обёрток, текст ошибки от базы, параметры запроса.
Идентификатор трассировки — связующее звено: клиент видит его в ответе, поддержка находит по нему путь запроса в журналах и трассах. Без него совет «причина в журналах» неисполним.
Чего в ответе быть не должно: err.Error() целиком (там текст pgconn со схемой и SQL), имена пакетов и файлов, значения параметров. Поэтому в Problem уходит appErr.Msg, который написан для человека, а не err.Error().
Ожидаемые ошибки: тише в журнале, но считать
404 и 409 пишут уровнем отладки, иначе журнал прода тонет в ожидаемом. Вторая половина правила, без которой первая вредна: ошибка, исчезнувшая из журнала, должна появиться в метриках.
var apiErrors = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "api_errors_total",
Help: "Ошибки API по машинному коду",
}, []string{"code"})
func logExpected(r *http.Request, e *apperr.Error, status int) {
apiErrors.WithLabelValues(e.Code).Inc()
level := slog.LevelDebug
if status >= http.StatusInternalServerError {
level = slog.LevelError
}
slog.Log(r.Context(), level, "request failed", "code", e.Code, "status", status, "err", e.Err)
}
Всплеск «не найдено» означает либо сломанного клиента, либо потерянные данные, и заметить его можно только по графику. Оповещение ставят на долю от общего потока, а не на абсолютное число.
Ошибки проверок в том же формате
Нарушения валидации — та же apperr.Error вида KindInvalid, только со списком:
type Violation struct {
Field string `json:"field"`
Message string `json:"message"`
Rule string `json:"rule"`
}
func Invalid(violations []Violation) error {
return &Error{Kind: KindInvalid, Code: "VALIDATION_FAILED", Msg: "проверка не пройдена", Violations: violations}
}
httperr.Write кладёт Violations в Problem, и ошибки формы приходят в том же конверте, что и доменные. Сломанный JSON (json.Decoder вернул *json.SyntaxError) превращается в KindInvalid без списка — ему отдать нечего, кроме общего сообщения. Как собрать список из validator — в статье про свои правила валидации.
Тест обработчика
Тестируют не httperr.Write напрямую, а путь запроса целиком: роутер, middleware, обработчик, адаптер, сериализацию. httptest делает это без сети, а сценарий подменяется заглушкой:
func TestErrors(t *testing.T) {
orders := &stubOrders{err: apperr.Rule("ORDER_CANNOT_BE_CANCELLED", "заказ уже отправлен")}
srv := app.NewRouter(orders)
cases := []struct {
name string
method string
path string
body string
status int
code string
}{
{"доменная ошибка 422", http.MethodPost, "/api/v1/orders/42/cancel", "", 422, "ORDER_CANNOT_BE_CANCELLED"},
{"сломанный JSON 400", http.MethodPost, "/api/v1/orders", "{", 400, "VALIDATION_FAILED"},
{"неизвестный путь 404", http.MethodGet, "/api/v1/nothing", "", 404, "ROUTE_NOT_FOUND"},
{"без токена 401", http.MethodGet, "/api/v1/orders/42", "", 401, "UNAUTHENTICATED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status %d, want %d: %s", rec.Code, tc.status, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("content-type %q", ct)
}
var p struct {
Code string `json:"code"`
TraceID string `json:"traceId"`
Detail string `json:"detail"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatal(err)
}
if p.Code != tc.code {
t.Errorf("code %q, want %q", p.Code, tc.code)
}
if strings.Contains(p.Detail, "pgconn") || strings.Contains(p.Detail, "SQLSTATE") {
t.Errorf("утечка внутренностей: %q", p.Detail)
}
})
}
}
Что важно в этом тесте. Проверяется тип содержимого — его легко потерять при ручной сборке ответа. Проверяется, что в detail не утекли внутренности: проверка того, чего быть не должно, важнее проверки того, что должно быть. И четыре сквозных случая покрыты один раз на приложение, а не на каждый обработчик: сломанный JSON, неизвестный путь, отсутствие токена и доменная ошибка. Пятым добавляют панику в заглушке и ждут 500 с traceId и без стека в теле.
Коротко
- В Go единый обработчик — соглашение: обработчики возвращают
error, адаптерHandleпередаёт его в одну функциюhttperr.Write. - Ошибка несёт вид (
apperr.Kind) и машинный код, а не статус; соответствие «вид → статус» живёт в веб-слое однимswitch. errors.Asчитает вид сквозь любые обёртки%w— это замена иерархии исключений; ветка по умолчанию —500.- Ошибки базы (
pgx.ErrNoRows, код23505) превращаются в доменные на границе репозитория, иначе404и409станут500. - Адаптер не видит ошибок middleware, роутера и паники:
401изAuth,NotFoundиMethodNotAllowedу chi, свойRecover— все пишут через тот жеhttperr.Write. - Разный формат для разных API — разные писатели у поддеревьев маршрутов, без приоритетов и порядка.
- Клиенту уходят статус, код, сообщение для человека и
traceId;err.Error()с текстом от базы остаётся в журнале. - Ожидаемые ошибки пишут уровнем отладки, но считают счётчиком по коду; оповещение — на долю, не на число.
- Ошибки проверок и сломанный JSON — тот же конверт
KindInvalid; обработчик тестируют табличным тестом черезhttptestна статус, тип содержимого, код и отсутствие утечки.
Что почитать дальше
- Ошибки REST API в Go: RFC 9457 — структура
Problem, свойContent-Type, список нарушений. - Обработка ошибок в Go — обёртки
%w,errors.Isиerrors.As, ошибки как значения. - Модель ошибок приложения — как выбрать структуру тела ошибки и когда нужны расширения.
- Свои правила валидации и сообщения в Go — откуда берётся список нарушений.