Опирается на правила:
R-ERR-1..9иR-ERR-X1..X4→ раздел Ошибки RFC 9457.
Важно знать
Content-Type: application/problem+jsonна всех ошибочных ответах.type— URNurn:problem:<service>:<code-kebab>, стабильный для категории.code— UPPER_SNAKE_CASE, для программной логики на клиенте.traceId— из контекста трассировки (OTeltraceparent).- Единый
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-X1 | application/problem+json |
type: "about:blank" | R-ERR-X2 | urn:problem:<service>:<code> |
422 Unprocessable Entity для валидации | R-ERR-X3 | 400 Bad Request |
Stack trace в detail 500 | R-ERR-X4 | traceId для диагностики |
| SQL-запрос в теле ошибки | R-ERR-X4 | generic message |
| Разные структуры ошибок в разных хендлерах | R-PRIN-2 | единый httperr.Write |
err.Error() из адаптера напрямую в detail | R-ERR-X4 | санитизировать через KindOf |
Куда дальше
- go/headers.md —
traceparent→traceId. - go/json-and-responses.md — success format vs error.
- go/rate-limiting-files-deprecation.md —
429,410коды. - go/batch-async-localization.md — локализация
detail.