Every JSON error the framework sends has one shape, whatever produced it —
the exception mapping, the default-deny 401/403, CSRF, the rate limiter, a
malformed body, an unknown route or method:
{"error": {"type": "validation-error",
"message": "Request validation failed",
"details": {"email": ["invalid"]},
"correlation-id": "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40"}}
type and message are always there. details and correlation-id are
there when the error has them, and dev (the BND code, dev only) sits beside
them. Everything is inside error, so a client reads one key. The correlation
id is also the X-Correlation-ID response header.
type is the :type of the thrown ex-info, looked up in
problem-details/default-error-mappings for its status. Anything unmapped is a
500 whose type is internal-error and whose message says nothing more. Build
a body of your own with wagoe.platform.core.http.errors/body or response.
The types the framework answers with:
| Status | Type | Meaning |
|---|
400 | validation-error
| The request was read, and its input refused. |
400 | malformed-request
| The body does not parse as its Content-Type. Nothing else. |
400 | business-rule-violation, invalid-request
| Thrown by application code; the framework maps them. |
401 | unauthorized, auth-failed
| No valid credential. |
403 | forbidden, csrf-validation-failed, deletion-not-allowed, hard-deletion-not-allowed
| Known caller, not allowed. |
404 | not-found, user-not-found, resource-not-found
| Nothing there. |
405 | method-not-allowed
| The path exists; the method does not. |
409 | conflict, user-exists, resource-exists
| It already exists, or changed underneath. |
422 | unprocessable; a workflow transition’s refusal (transition-not-found, insufficient-permissions, guard-rejected, guard-not-registered)
| Well-formed, and still refused. |
429 | rate-limit-exceeded
| Too many requests. |
500 | internal-error
| Our fault; the log has the rest. |
500 | missing-error-type
| Dev only: an ex-info without :type reached the boundary. |
501 | not-supported
| Not on this database or configuration. |
503 | unavailable
| The server has no handler yet. |
A write that a unique or foreign key refuses is a 409 conflict, on H2,
SQLite and PostgreSQL alike: wagoe.platform.database throws it, so every
generated repository gets it. details names the constraint and, when the
driver names one column, the field; nothing else of the driver’s text is sent.
{"error": {"type": "conflict",
"message": "Another record already has this number",
"details": {"constraint": "unique", "field": "number"}}}
A handler that knows only its status gets its type from errors/status→type:
validation-error for a 4xx with no type of its own, internal-error for a 5xx.