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