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

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

Стандарт RFC 9457 (Problem Details for HTTP APIs) решает это: описывает единый JSON-формат для ошибок и специальный Content-Type. В этой статье разберём, как применять его в Go.

Что такое RFC 9457 и зачем он нужен

Раньше ошибки возвращали в произвольном формате:

{"error": "not found"}
{"message": "invalid input", "fields": [...]}
{"statusCode": 400, "msg": "bad request"}

Каждый сервис — по-своему. Клиентам приходилось знать структуру каждого сервиса отдельно.

RFC 9457 стандартизирует формат ошибки: один и тот же набор полей, один и тот же Content-Type: application/problem+json. Клиент знает, где искать код ошибки, где — описание, где — детали.

Структура ответа с ошибкой

Базовое тело ошибки выглядит так:

{
  "type": "urn:problem:order-service:order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Заказ с id 42 не найден",
  "code": "ORDER_NOT_FOUND",
  "traceId": "00-1f2a8b6c..."
}

Что означает каждое поле:

  • type — стабильный идентификатор категории ошибки в формате URN. По нему клиент может отличить «заказ не найден» от «пользователь не найден», даже если оба дают 404.
  • title — короткое название, соответствующее HTTP-коду (Not Found, Bad Request).
  • status — числовой HTTP-код (дублируется в теле для удобства парсинга).
  • detail — человекочитаемое объяснение, что именно пошло не так.
  • code — машиночитаемый код в UPPER_SNAKE_CASE. Клиент использует его в if-условиях, а не парсит detail.
  • traceId — идентификатор запроса для диагностики (берётся из контекста трассировки OTel).

В Go это две структуры:

type ProblemDetails struct {
    Type    string `json:"type"`
    Title   string `json:"title"`
    Status  int    `json:"status"`
    Detail  string `json:"detail"`
    Code    string `json:"code"`
    TraceID string `json:"traceId,omitempty"`
}

type ValidationProblem struct {
    ProblemDetails
    Violations []Violation `json:"violations"`
}

type Violation struct {
    Field   string `json:"field"`
    Code    string `json:"code"`
    Message string `json:"message"`
}

ValidationProblem расширяет базовую структуру полем violations — списком конкретных полей, которые не прошли проверку. Это нужно для 400 Bad Request, чтобы клиент знал, что именно исправить.

Как строится поле type

type — это URN вида urn:problem:<service>:<code-kebab>. Он стабильный: если код ошибки не меняется, type тоже не меняется. Клиенты могут сохранять его как константу и делать switch по нему.

const serviceName = "order-service"

func problemType(errorCode string) string {
    // ORDER_NOT_FOUND → urn:problem:order-service:order-not-found
    kebab := strings.ToLower(strings.ReplaceAll(errorCode, "_", "-"))
    return "urn:problem:" + serviceName + ":" + kebab
}

Единый обработчик ошибок

Главный принцип: все обработчики (handlers) используют одну функцию для записи ошибки в ответ. Никто не пишет application/problem+json вручную — только через httperr.Write.

Это гарантирует: формат одинаковый во всём сервисе, Content-Type всегда правильный, случайно не просочатся внутренние детали.

package httperr

func Write(w http.ResponseWriter, r *http.Request, err error) {
    traceID := traceIDFromCtx(r.Context())

    switch apperr.KindOf(err) {
    case apperr.KindNotFound:
        writeProblem(w, http.StatusNotFound,
            "NOT_FOUND", "Not Found", err.Error(), traceID)
    case apperr.KindValidation:
        writeProblem(w, http.StatusBadRequest,
            "VALIDATION_ERROR", "Bad Request", err.Error(), traceID)
    case apperr.KindConflict:
        writeProblem(w, http.StatusConflict,
            "CONFLICT", "Conflict", err.Error(), traceID)
    case apperr.KindForbidden:
        writeProblem(w, http.StatusForbidden,
            "FORBIDDEN", "Forbidden", err.Error(), traceID)
    case apperr.KindUnauthorized:
        writeProblem(w, http.StatusUnauthorized,
            "UNAUTHORIZED", "Unauthorized", err.Error(), traceID)
    default:
        slog.ErrorContext(r.Context(), "unexpected error", "err", err)
        writeProblem(w, http.StatusInternalServerError,
            "INTERNAL_SERVER_ERROR", "Internal Server Error",
            "Внутренняя ошибка сервера", traceID)
    }
}

apperr.KindOf читает из ошибки её «вид» (KindNotFound, KindConflict и т.д.) и превращает его в нужный HTTP-код. Доменный слой возвращает типизированные ошибки с методом Kind():

type OrderNotFoundError struct{ ID string }

func (e *OrderNotFoundError) Error() string { return "order not found: " + e.ID }
func (e *OrderNotFoundError) Kind() apperr.Kind { return apperr.KindNotFound }

// httperr.Write определит Kind и вернёт 404

Когда нужен специфичный код вместо общего NOT_FOUND — расширяй Write проверкой через errors.As:

func Write(w http.ResponseWriter, r *http.Request, err error) {
    traceID := traceIDFromCtx(r.Context())

    var orderErr *OrderNotFoundError
    if errors.As(err, &orderErr) {
        writeProblem(w, http.StatusNotFound,
            "ORDER_NOT_FOUND", "Not Found",
            "Заказ с id "+orderErr.ID+" не найден", traceID)
        return
    }
    // ... дальше общий switch по KindOf
}

Вспомогательная функция записи:

func writeProblem(w http.ResponseWriter, status int, code, title, detail, traceID string) {
    p := ProblemDetails{
        Type:    problemType(code),
        Title:   title,
        Status:  status,
        Detail:  detail,
        Code:    code,
        TraceID: traceID,
    }
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(p)
}

Валидационные ошибки

Когда клиент прислал некорректные данные, возвращается 400 Bad Request с полем violations — списком всех проблемных полей. Это позволяет форме на клиенте показать ошибки рядом с нужными полями, а не одно общее сообщение.

Маппинг из go-playground/validator:

func toViolations(errs validator.ValidationErrors) []Violation {
    out := make([]Violation, 0, len(errs))
    for _, e := range errs {
        out = append(out, Violation{
            Field:   fieldPath(e),
            Code:    strings.ToUpper(e.Tag()),
            Message: e.Translate(trans),
        })
    }
    return out
}

func fieldPath(e validator.FieldError) string {
    ns := e.Namespace()
    // убираем имя структуры: "CreateOrderRequest.Items[0].Quantity" → "items[0].quantity"
    parts := strings.SplitN(ns, ".", 2)
    if len(parts) < 2 {
        return strings.ToLower(ns)
    }
    return toLowerCamel(parts[1])
}

Пример ответа при валидационной ошибке:

{
  "type": "urn:problem:order-service:validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "Request contains invalid fields",
  "code": "VALIDATION_ERROR",
  "traceId": "00-1f2a8b6c...",
  "violations": [
    {
      "field": "items[0].quantity",
      "code": "MIN",
      "message": "Количество должно быть от 1 до 99"
    },
    {
      "field": "deliveryAddress.zipCode",
      "code": "REQUIRED",
      "message": "Почтовый индекс обязателен"
    }
  ]
}

Важно: для валидационных ошибок всегда 400, не 422. Код 422 Unprocessable Entity означает семантически неверный запрос (например, правильный JSON, но бизнес-правило нарушено), и то его использование спорно. Для ошибок полей формы — только 400.

Защита от утечки внутренностей

Одна из частых ошибок — включить в ответ стек трейс, SQL-запрос или системный путь к файлу. Клиент увидит это, и хуже — это окажется в логах, которые может прочитать посторонний.

Для защиты добавляют middleware, который перехватывает panic и возвращает безопасный ответ 500:

func Recoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rc := recover(); rc != nil {
                slog.ErrorContext(r.Context(), "panic recovered",
                    "panic", rc,
                    "stack", debug.Stack(), // пишем в лог, не в ответ
                )
                traceID := traceIDFromCtx(r.Context())
                writeProblem(w, http.StatusInternalServerError,
                    "INTERNAL_SERVER_ERROR", "Internal Server Error",
                    "Внутренняя ошибка сервера", traceID)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Стек трейс уходит только в лог. В ответе клиенту — traceId, по которому можно найти нужную запись в системе наблюдения.

Частые ошибки

Неправильный Content-Type. Если ошибка возвращается с Content-Type: application/json вместо application/problem+json — клиент не знает, что это problem details. Всегда используй application/problem+json для ошибок.

type: "about:blank". Это заглушка из RFC для случаев, когда нет конкретного типа. На практике так делать не стоит — клиент не может различить разные ошибки. Указывай конкретный URN.

err.Error() напрямую в detail для 500. Строка ошибки из адаптера или базы данных может содержать SQL, пути, имена таблиц. Для 500 всегда пиши безопасное общее сообщение, а диагностику отдавай через traceId.

Разные структуры в разных обработчиках. Если один обработчик возвращает {"error": "..."}, а другой — {"type": "..."}, клиент сломается. Единый httperr.Write решает это.

Коротко

  • RFC 9457 задаёт стандартный формат ошибок с полями type, title, status, detail, code, traceId.
  • Content-Type: application/problem+json — всегда на ошибочных ответах.
  • type — стабильный URN вида urn:problem:<service>:<code-kebab>, не меняется при одном и том же виде ошибки.
  • code в UPPER_SNAKE_CASE — для программной логики клиента.
  • Валидационные ошибки — 400 с полем violations, никогда не 422.
  • Единый httperr.Write — все обработчики через него, никто не пишет JSON вручную.
  • Стек трейс, SQL, пути — только в лог, никогда в тело ответа; traceId — для диагностики.

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

  • Заголовки — Idempotency-Key, traceparent — как traceparent превращается в traceId.
  • JSON и формат ответов — формат успешного ответа vs ошибка.