Опирается на правила: R-ERR-1..9 и R-ERR-X1..X4раздел Ошибки RFC 9457.

Важно знать

  • Content-Type: application/problem+json на всех ошибочных ответах.
  • type — URN urn:problem:<service>:<code-kebab>, стабильный для категории.
  • code — UPPER_SNAKE_CASE, для программной логики на клиенте.
  • traceId — из контекста трассировки (OTel traceparent).
  • Единый httperr.Write — все хендлеры используют его, не пишут problem вручную.
  • Валидация400 VALIDATION_ERROR + violations через go-playground/validator.
  • Никогда не 422, 418 — валидация это 400.
  • Стек трейс, SQL, пути в теле 500 — запрещены.

Структуры ProblemDetails

R-ERR-1..4:

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"`
}

Построение URN

R-ERR-2: urn:problem:<service>:<code-kebab>.

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
}

Запись ответа с ошибкой

R-ERR-3:

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)
}

func writeValidationProblem(w http.ResponseWriter, violations []Violation, traceID string) {
    p := ValidationProblem{
        ProblemDetails: ProblemDetails{
            Type:    problemType("VALIDATION_ERROR"),
            Title:   "Validation failed",
            Status:  http.StatusBadRequest,
            Detail:  "Request contains invalid fields",
            Code:    "VALIDATION_ERROR",
            TraceID: traceID,
        },
        Violations: violations,
    }
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(http.StatusBadRequest)
    json.NewEncoder(w).Encode(p)
}

Маппинг apperr.Kind → HTTP-код

R-ERR-9: единый httperr.Write читает тип ошибки через apperr.KindOf:

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)
    }
}

Типизированные ошибки с доменным кодом:

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

Для доменных кодов (ORDER_NOT_FOUND вместо общего NOT_FOUND) — расширяй:

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
    }
    // ... fallback к KindOf
}

Валидация — violations

R-ERR-5..6: маппинг go-playground/validator[]Violation.

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": "Почтовый индекс обязателен"
    }
  ]
}

Recoverer middleware

Предотвращает утечку стека в ответ (R-ERR-X4):

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)
    })
}

Локализация detail и message

R-LOC-1..3: detail и violations.message локализуются по Accept-Language.

func localizedDetail(r *http.Request, ru, en string) string {
    lang := acceptLanguage(r)
    if strings.HasPrefix(lang, "en") {
        return en
    }
    return ru
}

// в httperr.Write:
detail := localizedDetail(r,
    "Заказ с указанным id не найден",
    "Order not found")

R-LOC-X1: code и type — всегда английские.

Что запрещено

АнтипаттернПравилоЧто взамен
Content-Type: application/json для ошибкиR-ERR-X1application/problem+json
type: "about:blank"R-ERR-X2urn:problem:<service>:<code>
422 Unprocessable Entity для валидацииR-ERR-X3400 Bad Request
Stack trace в detail 500R-ERR-X4traceId для диагностики
SQL-запрос в теле ошибкиR-ERR-X4generic message
Разные структуры ошибок в разных хендлерахR-PRIN-2единый httperr.Write
err.Error() из адаптера напрямую в detailR-ERR-X4санитизировать через KindOf

Куда дальше

  • go/headers.md — traceparenttraceId.
  • go/json-and-responses.md — success format vs error.
  • go/rate-limiting-files-deprecation.md — 429, 410 коды.
  • go/batch-async-localization.md — локализация detail.