Skip to content

Errors on the Wire

wiki edited this page Sep 4, 2026 · 1 revision

Errors on the wire

Every error rex and its extensions produce is an RFC 9457 problem document, served as application/problem+json. One format for the whole stack, so a client parses one shape rather than four.

{
  "type": "urn:rex:problem:method-not-allowed",
  "title": "Method Not Allowed",
  "status": 405,
  "detail": "this method is not supported for this path",
  "allowed_methods": ["GET", "PUT", "OPTIONS"]
}

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

Writing one

rextension.WriteProblem(w, r, http.StatusNotFound,
	rextension.ProblemNotFound, "no resource exists at this path")

Or build it up:

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

The rule

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 cause belongs in the log, joined to the response by instance:

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

rextension.NewProblem(500, 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 in both the response and the log is what makes the safe-text rule workable rather than merely restrictive.

What the framework itself answers

Situation Status Slug
no route matches the path 404 not-found
path exists under other methods 405 + Allow + allowed_methods method-not-allowed
Content-Length over the route's cap 413 + max_bytes payload-too-large
a request reached a router before its table was built 503 internal

The 404's detail is deliberately uninformative. "path not under base URL /api" tells a prober how the application is mounted; the real reason is logged instead.

OPTIONS with a non-empty Allow set is answered 204 with the header, not as a problem.

Extension slugs

Appended to the type base. Extensions use these, and applications add their own.

unauthorized · forbidden · not-found · method-not-allowed · not-acceptable · unsupported-media-type · payload-too-large · validation-failed · rate-limit-exceeded · internal · dependency-unavailable · bad-request

Each has a constant: rextension.ProblemUnauthorized, and so on.

Field-level validation errors

rextension-validation answers 422 with the errors extension member:

{
  "type": "urn:rex:problem:validation-failed",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "the request body is invalid",
  "errors": [
    {"field": "email", "rule": "email", "message": "must be a valid address"},
    {"field": "age", "rule": "min", "message": "must be at least 18", "value": "12"}
  ]
}

field is the JSON name, not the Go field name. value is omitted for anything that could be a credential.

Pointing types at your own documentation

The default type base is a URN — urn:rex:problem: — because a framework has no domain it can promise will still serve those pages. A URN is stable and commits the project to no URL that could later 404.

If you do publish documentation, point 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 Accept. 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 went wrong.

Member order is stable

Problem implements MarshalJSONTo and writes the RFC's members in the RFC's order, then extension members — so a document's shape does not change depending on whether an extension member happens to be present. Handy for log-reading and for pinning responses in tests.

Clone this wiki locally