Опирается на правила: R-ERR-MAP-1R-ERR-MAP-5 и R-ERR-MAP-X1R-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), запросы к внешке не идут.
  • 504context.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-X1httperr.Write(w, r, err) с нужным статусом
detail: err.Error() для технической ошибкиR-ERR-MAP-X3sanitize(err) — фраза без internal state
detail: fmt.Sprintf("%+v", err) — stacktrace в ответR-ERR-MAP-X2stacktrace только в slog.ErrorContext, не в response
detail: pgErr.Message от pgx — строка от БДR-ERR-MAP-X3фиксированная фраза по типу ошибки
Нет Content-Type: application/problem+jsonR-ERR-MAP-1w.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 — нормативные формулировки.