Опирается на правила:
R-ERR-MAP-1…R-ERR-MAP-5иR-ERR-MAP-X1…R-ERR-MAP-X3из Error Handling Style Guide → раздел 3. Mapping в problem+json.
Важно знать
Content-Type: application/problem+jsonна всех error-response — без исключений.- Domain → 422 (нарушение инварианта) или 409 (конфликт состояния).
type= URL на карточку ошибки вdocs/spec/errors/.- Validation → 400 +
errors-массив per-field (поляgo-playground/validatorприводятся к единой форме).- Integration → 502 (5xx внешки) / 503 (CB открыт) / 504 (timeout). Сырое тело внешки в
detailне вкладываем (PII).- Technical → 500, минимум в response. Детали — только в логи.
err.Error()без санитизации вdetail— запрещён. Строка отdatabase/sqlилиnet/httpраскрывает схему и internal paths.- Stacktrace в
detail— запрещён. Только в логи.- HTTP 200 с
{"success": false}— запрещён. Мониторинг прозевает.
RFC 9457 — это стандарт HTTP-ответа при ошибке: application/problem+json с полями type, title, status, detail, и свободными extension-полями. В Go нет встроенного ProblemDetail (как в Spring Boot 3) — делаем вручную. Это несложно, но требует единственного рендерера на весь сервис. Раскрытие правил R-ERR-MAP-* ниже.
Структура ProblemDetails в Go
Базовая структура и рендерер:
// edge/httperr/problem.go
package httperr
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
TraceID string `json:"traceId,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
Extra map[string]any `json:"-"`
}
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// edge/httperr/render.go
func Write(w http.ResponseWriter, r *http.Request, err error) {
kind := apperr.KindOf(err)
status, title := mapKind(kind, err)
p := &Problem{
Type: typeURL(kind, err),
Title: title,
Status: status,
Detail: sanitize(err),
TraceID: traceID(r),
}
enrichExtensions(p, err)
logByKind(r.Context(), kind, err)
appErrorsTotal.WithLabelValues(kindLabel(kind), typeName(err)).Inc()
out, _ := json.Marshal(mergeExtra(p))
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_, _ = w.Write(out)
}
R-ERR-MAP-1 — Domain → 409 / 422
Доменные ошибки делятся на два подтипа:
- 409 Conflict — нарушение текущего состояния ресурса (
OrderAlreadyShippedError,ProductAlreadyPublishedError). - 422 Unprocessable Entity — нарушение бизнес-инварианта при правильном формате (
InsufficientFundsError,CustomerNotEligibleForRefundError).
func mapKind(k apperr.Kind, err error) (int, string) {
switch k {
case apperr.Domain:
if isConflict(err) {
return http.StatusConflict, "Resource state conflict"
}
return http.StatusUnprocessableEntity, "Operation cannot be completed"
case apperr.Validation:
return http.StatusBadRequest, "Validation failed"
case apperr.Integration:
return mapIntegration(err)
default:
return http.StatusInternalServerError, "Internal Server Error"
}
}
func isConflict(err error) bool {
var shipped *order.OrderAlreadyShippedError
var published *product.ProductAlreadyPublishedError
return errors.As(err, &shipped) || errors.As(err, &published)
}
type = URL на карточку ошибки в docs/spec/errors/:
func typeURL(k apperr.Kind, err error) string {
var insuf *order.InsufficientFundsError
if errors.As(err, &insuf) {
return "https://api.example.ru/errors/insufficient-funds"
}
var shipped *order.OrderAlreadyShippedError
if errors.As(err, &shipped) {
return "https://api.example.ru/errors/order-already-shipped"
}
return "about:blank"
}
Extension-поля из контекста доменной ошибки:
func enrichExtensions(p *Problem, err error) {
var insuf *order.InsufficientFundsError
if errors.As(err, &insuf) {
p.Extra = map[string]any{
"customerId": insuf.CustomerID,
"requested": insuf.Requested,
"available": insuf.Available,
}
return
}
var oos *product.ProductOutOfStockError
if errors.As(err, &oos) {
p.Extra = map[string]any{
"productId": oos.ProductID,
"available": oos.Available,
}
}
}
Пример ответа:
{
"type": "https://api.example.ru/errors/insufficient-funds",
"title": "Operation cannot be completed",
"status": 422,
"detail": "insufficient funds",
"customerId": "cust-7812",
"requested": 5000,
"available": 200,
"traceId": "4bf92f3577b34da6"
}
R-ERR-MAP-2 — Validation → 400 + errors[]
Ошибки валидатора (go-playground/validator) приводятся к единой форме с массивом errors:
// edge/validation/validate.go
package validation
import (
"errors"
"github.com/go-playground/validator/v10"
"git.example.ru/sber/edge/httperr"
)
func Bind(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
httperr.Write(w, r, &ValidationError{Message: "invalid request body"})
return false
}
if err := validate.Struct(dst); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
httperr.WriteValidation(w, r, toFieldErrors(ve))
return false
}
}
return true
}
func toFieldErrors(ve validator.ValidationErrors) []httperr.FieldError {
out := make([]httperr.FieldError, len(ve))
for i, fe := range ve {
out[i] = httperr.FieldError{
Field: fe.Field(),
Message: fe.Tag(),
}
}
return out
}
// edge/httperr/render.go
func WriteValidation(w http.ResponseWriter, r *http.Request, fields []FieldError) {
p := &Problem{
Type: "about:blank",
Title: "Validation failed",
Status: http.StatusBadRequest,
TraceID: traceID(r),
Errors: fields,
}
appErrorsTotal.WithLabelValues("validation", "ValidationError").Inc()
out, _ := json.Marshal(p)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(out)
}
Пример ответа:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"traceId": "4bf92f3577b34da6",
"errors": [
{"field": "Quantity", "message": "min"},
{"field": "CustomerID", "message": "required"}
]
}
R-ERR-MAP-3 — Integration → 502 / 503 / 504
Три подстатуса — по причине сбоя:
func mapIntegration(err error) (int, string) {
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout, "Upstream timeout"
}
if errors.Is(err, gobreaker.ErrOpenState) {
return http.StatusServiceUnavailable, "Service temporarily unavailable"
}
return http.StatusBadGateway, "Upstream error"
}
- 502 — внешка вернула 5xx с телом или мы получили транспортную ошибку.
- 503 — circuit breaker открыт (
gobreaker.ErrOpenState), запросы к внешке не идут. - 504 —
context.DeadlineExceeded— внешка не ответила в срок.
detail — фраза без внутреннего состояния внешки. Сырое тело 5xx-ответа — PII-риск:
func sanitize(err error) string {
var gw *payment.GatewayError
if errors.As(err, &gw) {
return "payment service temporarily unavailable"
}
var cp *catalog.PortError
if errors.As(err, &cp) {
return "catalog service temporarily unavailable"
}
var insuf *order.InsufficientFundsError
if errors.As(err, &insuf) {
return "insufficient funds"
}
switch apperr.KindOf(err) {
case apperr.Domain:
return "operation cannot be completed"
case apperr.Integration:
return "upstream service error"
default:
return "internal error"
}
}
R-ERR-MAP-4 — Technical → 500
Technical и неклассифицированные ошибки → 500. В detail — только «internal error», детали идут в лог.
// mapKind default-ветка уже возвращает (500, "Internal Server Error")
// logByKind для Technical вызывает slog.ErrorContext с полным err
func logByKind(ctx context.Context, k apperr.Kind, err error) {
switch k {
case apperr.Domain:
slog.WarnContext(ctx, "domain rule violated", "error", err)
case apperr.Integration:
slog.WarnContext(ctx, "integration failure", "error", err)
case apperr.Validation:
// не логируем — validation ожидаема, сигнал только в метрику
default:
slog.ErrorContext(ctx, "unexpected error", "error", err)
}
}
R-ERR-MAP-5 — recover-middleware → 500 + stacktrace
panic от программистской ошибки перехватывается Recoverer-middleware, логируется ERROR со стектрейсом, клиент получает минимальный ответ:
func WritePanic(w http.ResponseWriter, r *http.Request) {
p := &Problem{
Type: "about:blank",
Title: "Internal Server Error",
Status: 500,
Detail: "internal error",
TraceID: traceID(r),
}
appErrorsTotal.WithLabelValues("unexpected", "panic").Inc()
out, _ := json.Marshal(p)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(500)
_, _ = w.Write(out)
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
w.WriteHeader(200); json.Encode(map{"success": false}) | R-ERR-MAP-X1 | httperr.Write(w, r, err) с нужным статусом |
detail: err.Error() для технической ошибки | R-ERR-MAP-X3 | sanitize(err) — фраза без internal state |
detail: fmt.Sprintf("%+v", err) — stacktrace в ответ | R-ERR-MAP-X2 | stacktrace только в slog.ErrorContext, не в response |
detail: pgErr.Message от pgx — строка от БД | R-ERR-MAP-X3 | фиксированная фраза по типу ошибки |
Нет Content-Type: application/problem+json | R-ERR-MAP-1 | w.Header().Set("Content-Type", "application/problem+json") |
R-ERR-MAP-X3 — типичная ошибка: передать err.Error() от pgx напрямую в detail.
// ПЛОХО
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
p.Detail = pgErr.Message // ← "relation \"order_doc\" does not exist"
}
// ХОРОШО
p.Detail = "internal error" // детали — в логи через slog.ErrorContext
Куда дальше
- Иерархия ошибок — какие типы с каким
Kind(). - Где return, где обрабатывать — как ошибка добирается до
httperr.Write. - Логирование ошибок — что и когда логировать внутри
logByKind. - Retry-семантика — зачем 502 vs 503 vs 504 важны для retry.
- Observability ошибок — метрика
app_errors_totalи span.RecordError. - Errors-as-values vs panic — когда
panicпопадает вRecoverer. - Shared-контракт R-ERR-MAP — нормативные формулировки.