← назад к разделу

В 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
KindUnauthorized401 Unauthorized
KindForbidden403 Forbidden
KindNotFound404 Not Found
KindConflict409 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 на статус, тип содержимого, код и отсутствие утечки.

Что почитать дальше