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

API: REST И КОНТРАКТЫ Ошибки RFC 9457

Ошибки REST API в Go: ProblemDetails

Go · RFC 9457

Единый машиночитаемый ответ с типом, статусом, нарушениями полей и безопасным описанием без внутреннего стека.

1. ProblemDetails имеет стабильную форму

1 / 5

application/problem+json содержит type, title, status и detail. Расширения добавляют code, instance и ошибки полей.

  • type ссылается на документацию проблемы.
  • status повторяет HTTP-статус ответа.
  • detail понятен человеку, code обрабатывается программой.
Content-Type: application/problem+json"type": "/problems/validation","title": "Validation failed","status": 400,"code": "VALIDATION_FAILED","violations": [...]

2. Единый writer не даёт форматам расползтись

2 / 5

Обработчики возвращают доменную ошибку, а центральный компонент переводит её в ProblemDetails и статус.

  • Не собирать JSON ошибки руками в каждом handler.
  • apperr.Kind отделяет validation, not found, conflict и internal.
  • Логирование и внешний ответ имеют разные детали.
Handler AHandler BError writerkind → statusProblemDetails

3. Валидация перечисляет нарушения полей

3 / 5

Клиент исправляет запрос быстрее, если получает все ошибки сразу: field, code и понятное message.

  • Название поля совпадает с JSON.
  • Код стабилен для программной обработки.
  • Сообщение не содержит правила SQL или внутреннего пакета.
"violations": [field=email · code=INVALID_FORMATfield=amount · code=MUST_BE_POSITIVE]

4. Внутренняя ошибка не раскрывает устройство

4 / 5

Клиент получает общий internal error и идентификатор обращения. Стек, SQL, адреса и секреты остаются в защищённых логах.

  • traceID связывает ответ с расследованием.
  • Причину retry сообщают явно только безопасным клиентам.
  • Not found не раскрывает чужой ресурс без необходимости.
Client response500 · INTERNAL_ERRORtraceID: abc-42Protected logsstack + SQL + causesame traceID

5. Статус выбирается по способу восстановления

5 / 5

400 требует исправить запрос, 404 искать другой ресурс, 409 разрешить конфликт, 429 подождать, 500 или 503 сигналит о сбое сервера.

  • Не возвращать 200 с ошибкой в теле.
  • Retry-After помогает временному отказу.
  • ProblemDetails сохраняется для всех семейств ошибок.
400 fix404 lookup409 conflict429 wait500 / 503 · server failureОдин формат, разные действия клиента

Когда что-то идёт не так, клиент должен получить понятный ответ: что случилось, какой код у проблемы, можно ли повторить запрос. Стандарт RFC 9457 (Problem Details for HTTP APIs) описывает единый формат такого ответа. В этой статье разберём, как применять его в 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 Content — стандартный код (RFC 9110), и часть фреймворков отдаёт им именно ошибки полей; но два кода на одну ситуацию заставляют клиента обрабатывать оба, поэтому здесь договорились об одном — 400.

В httperr.Write эту ветку подключают через errors.As(err, &ve) до общего switch: нашли validator.ValidationErrors — пишем ValidationProblem с violations, иначе обычный 400 с одним detail.

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

Одна из частых ошибок — включить в ответ стек трейс, SQL-запрос или системный путь к файлу. Клиент увидит устройство сервиса, а ответ осядет в чужих логах и кэшах. Первая линия защиты уже есть в httperr.Write: ветка default пишет в detail общую фразу, а err.Error() уходит только в лог. Вторая — 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 — для диагностики.

Что пощупать

Тело ошибки каталога в практикуме remodov/marketplace-system-go - Problem Details: type, title, status, detail, instance и карта errors с именами полей для 400; коды 404 и 409 отличают «нет такого» от «сейчас нельзя».

Код: problem.go, handler.go. Во взрослом каталоге ошибка несёт ещё и стабильный code: apperr.go задаёт вид и код, а problem.go переводит вид в статус и собирает type вида urn:problem:catalog:<CODE> — ровно по этой статье.

Сделаем сами

Ветка step-03-commands-and-errors.

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