← Back to the section

When something goes wrong, the client should get a clear response: what happened, what the problem code is, whether the request can be retried. Without an agreed-upon format, each service returns errors its own way — and the client is forced to guess the structure.

The RFC 9457 standard (Problem Details for HTTP APIs) solves this: it describes a single JSON format for errors and a dedicated Content-Type. In this article we'll look at how to apply it in Go.

What RFC 9457 is and why it's needed

Errors used to be returned in an arbitrary format:

{"error": "not found"}
{"message": "invalid input", "fields": [...]}
{"statusCode": 400, "msg": "bad request"}

Every service in its own way. Clients had to know the structure of each service separately.

RFC 9457 standardizes the error format: the same set of fields, the same Content-Type: application/problem+json. The client knows where to look for the error code, where for the description, where for the details.

The structure of an error response

The basic error body looks like this:

{
  "type": "urn:problem:order-service:order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Order with id 42 not found",
  "code": "ORDER_NOT_FOUND",
  "traceId": "00-1f2a8b6c..."
}

What each field means:

  • type — a stable identifier for the error category in URN format. It lets the client tell "order not found" from "user not found", even if both return 404.
  • title — a short name matching the HTTP code (Not Found, Bad Request).
  • status — the numeric HTTP code (duplicated in the body for parsing convenience).
  • detail — a human-readable explanation of what exactly went wrong.
  • code — a machine-readable code in UPPER_SNAKE_CASE. The client uses it in if conditions rather than parsing detail.
  • traceId — the request identifier for diagnostics (taken from the OTel trace context).

In Go this is two structs:

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 extends the base structure with a violations field — a list of the specific fields that failed validation. This is needed for 400 Bad Request, so the client knows exactly what to fix.

How the type field is built

type is a URN of the form urn:problem:<service>:<code-kebab>. It's stable: if the error code doesn't change, type doesn't change either. Clients can store it as a constant and switch on it.

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
}

A single error handler

The main principle: all handlers use one function to write an error into the response. Nobody writes application/problem+json by hand — only through httperr.Write.

This guarantees that the format is the same across the whole service, the Content-Type is always correct, and internal details won't accidentally leak.

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",
            "Internal server error", traceID)
    }
}

apperr.KindOf reads the error's "kind" (KindNotFound, KindConflict, etc.) and turns it into the appropriate HTTP code. The domain layer returns typed errors with a Kind() method:

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 will detect the Kind and return 404

When you need a specific code instead of the generic NOT_FOUND, extend Write with a check via 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",
            "Order with id "+orderErr.ID+" not found", traceID)
        return
    }
    // ... then the generic switch on KindOf
}

The helper writing function:

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

Validation errors

When the client sends invalid data, a 400 Bad Request is returned with a violations field — a list of all the problematic fields. This lets a form on the client show errors next to the relevant fields rather than one generic message.

Mapping from 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()
    // strip the struct name: "CreateOrderRequest.Items[0].Quantity" → "items[0].quantity"
    parts := strings.SplitN(ns, ".", 2)
    if len(parts) < 2 {
        return strings.ToLower(ns)
    }
    return toLowerCamel(parts[1])
}

An example response for a validation error:

{
  "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": "Quantity must be between 1 and 99"
    },
    {
      "field": "deliveryAddress.zipCode",
      "code": "REQUIRED",
      "message": "ZIP code is required"
    }
  ]
}

Important: for validation errors always 400, not 422. The 422 Unprocessable Entity code means a semantically invalid request (for example, valid JSON but a violated business rule), and even then its use is debatable. For form field errors — only 400.

Protection against leaking internals

One of the common mistakes is including a stack trace, a SQL query, or a system file path in the response. The client will see it, and worse — it will end up in logs that an outsider might read.

To protect against this, add middleware that intercepts panics and returns a safe 500 response:

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(), // write to the log, not the response
                )
                traceID := traceIDFromCtx(r.Context())
                writeProblem(w, http.StatusInternalServerError,
                    "INTERNAL_SERVER_ERROR", "Internal Server Error",
                    "Internal server error", traceID)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

The stack trace goes only to the log. In the client response — a traceId, by which you can find the relevant record in the observability system.

Common mistakes

Wrong Content-Type. If the error is returned with Content-Type: application/json instead of application/problem+json, the client doesn't know it's problem details. Always use application/problem+json for errors.

type: "about:blank". This is a placeholder from the RFC for cases where there's no specific type. In practice you shouldn't do this — the client can't distinguish between different errors. Specify a concrete URN.

err.Error() straight into detail for 500. An error string from an adapter or the database may contain SQL, paths, table names. For 500 always write a safe generic message and hand off diagnostics via traceId.

Different structures in different handlers. If one handler returns {"error": "..."} and another returns {"type": "..."}, the client breaks. A single httperr.Write solves this.

In short

  • RFC 9457 defines a standard error format with the fields type, title, status, detail, code, traceId.
  • Content-Type: application/problem+json — always on error responses.
  • type — a stable URN of the form urn:problem:<service>:<code-kebab>, unchanged for the same kind of error.
  • code in UPPER_SNAKE_CASE — for the client's programmatic logic.
  • Validation errors — 400 with a violations field, never 422.
  • A single httperr.Write — all handlers go through it, nobody writes JSON by hand.
  • Stack trace, SQL, paths — only to the log, never in the response body; traceId — for diagnostics.
  • Headers — Idempotency-Key, traceparent — how traceparent turns into traceId.
  • JSON and response format — the format of a successful response vs an error.