Skip to content

Problem Details

wiki edited this page Sep 4, 2026 · 1 revision

Problem details (RFC 9457)

One error format for the whole stack. Every extension in the ecosystem answers with it, so a client parses one shape rather than four.

RFC 9457 obsoletes RFC 7807. The media type and member names are unchanged, so a client written against 7807 reads 9457 without modification.

Writing one

rextension.WriteProblem(w, r, http.StatusUnauthorized,
	rextension.ProblemUnauthorized, "credentials were not accepted")

Or build it up when you need more than a detail line:

rextension.NewProblem(http.StatusTooManyRequests,
		rextension.ProblemRateLimitExceeded, "rate limit exceeded").
	WithInstance(requestID).
	WithExtra("retry_after_seconds", 30).
	Write(w, r)

Produces:

{
  "type": "urn:rex:problem:rate-limit-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "rate limit exceeded",
  "instance": "urn:rex:request:01H…",
  "retry_after_seconds": 30
}

Content-Type: application/problem+json.

The document

Member Meaning
type identifies the problem type. ProblemTypeBase + slug
title short summary of the type — identical for every occurrence
status the HTTP status, duplicated so the body is self-describing when logged or forwarded
detail explanation of this occurrence
instance this specific occurrence: InstanceBase + request id
errors extension member: per-field validation failures
(Extra) any additional extension members, flattened into the object

The one rule that matters

detail is safe text only. Never assign err.Error() to it.

An internal error's text routinely carries a table name, a file path, a connection string, or the shape of an internal service — none of which a client needs and all of which help an attacker.

The real cause belongs in the log, joined to the response by instance:

id := requestID(r)
log.WithError(err).WithField("request_id", id).Error("checkout failed")

rextension.NewProblem(http.StatusInternalServerError,
		rextension.ProblemInternal, "the request could not be completed").
	WithInstance(id).
	Write(w, r)

There is deliberately no separate trace_id member. instance is what the RFC provides for exactly this, and one identifier appearing in both the response and the log line is what makes the safe-text rule workable rather than merely restrictive: a client reports the value, and the operator finds the real cause under it.

Framework problem slugs

Appended to the type base. These are the errors the framework and its extensions produce; an application adds its own.

Slug constant Value
ProblemUnauthorized unauthorized
ProblemForbidden forbidden
ProblemNotFound not-found
ProblemMethodNotAllowed method-not-allowed
ProblemNotAcceptable not-acceptable
ProblemUnsupportedMediaType unsupported-media-type
ProblemPayloadTooLarge payload-too-large
ProblemValidationFailed validation-failed
ProblemRateLimitExceeded rate-limit-exceeded
ProblemInternal internal
ProblemDependencyUnavailable dependency-unavailable
ProblemBadRequest bad-request

Field-level errors

type FieldError struct {
	Field   string `json:"field"`             // the JSON name, not the Go field name
	Rule    string `json:"rule,omitempty"`    // "required", "email", "max"
	Message string `json:"message"`
	Value   string `json:"value,omitempty"`   // omitted for anything credential-shaped
}
rextension.NewProblem(422, rextension.ProblemValidationFailed, "the request body is invalid").
	WithErrors(
		rextension.FieldError{Field: "email", Rule: "email", Message: "must be a valid address"},
		rextension.FieldError{Field: "age", Rule: "min", Message: "must be at least 18", Value: "12"},
	).
	Write(w, r)

RFC 9457 §3.2 permits extension members, and a consumer that does not know errors ignores it. This is what replaces the validation extension's bespoke envelope, so field-level detail survives the format change.

Why the type base is a URN

const DefaultProblemTypeBase = "urn:rex:problem:"

The RFC says type should be a URI that dereferences to human-readable documentation, but does not require it — and a framework has no domain it can promise will still serve those pages. A URN is stable, identifies the problem unambiguously, and commits the project to no URL that could later 404.

An application that does publish documentation points the base at it, once, at startup:

func main() {
	rextension.ProblemTypeBase = "https://api.example.com/problems/"
	rextension.InstanceBase = "https://api.example.com/requests/"
	// …
}

Both are package-level and read on every construction, so set them before serving.

Content negotiation does not apply

Write sets application/problem+json regardless of the request's Accept header. Negotiation applies to success responses: a client that asked for application/xml and then made a mistake is better served by a machine-readable problem document it did not ask for than by a 406 carrying no information about what actually went wrong. RFC 9457 §3 anticipates this.

Write is also safe to call on a nil *Problem, in which case it writes nothing.

Member order is stable

Problem implements MarshalJSONTo (encoding/json/v2) and writes the RFC's members in the RFC's order, then extension members. encoding/json v1 honours it too, so a caller on either API gets the same document.

This replaced a MarshalJSON that marshalled the struct, unmarshalled into a map, merged, and marshalled again — which sorted the keys, so adding a single extension member reordered the whole document, and which cost three passes on a path that runs under exactly the load a 429 exists to shed.

Clone this wiki locally