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 return404.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 inifconditions rather than parsingdetail.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 formurn:problem:<service>:<code-kebab>, unchanged for the same kind of error.codein UPPER_SNAKE_CASE — for the client's programmatic logic.- Validation errors —
400with aviolationsfield, never422. - 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.
What to read next
- Headers — Idempotency-Key, traceparent — how
traceparentturns intotraceId. - JSON and response format — the format of a successful response vs an error.