-
Notifications
You must be signed in to change notification settings - Fork 0
Problem Details
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.
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.
| 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 |
detailis safe text only. Never assignerr.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.
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 |
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.
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.
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.
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.
rextension — the Rex extension contract · MIT · © 2026 Kryovyx · pre-1.0, interfaces may change
Building an extension
Contracts
Reference
Ecosystem