Errors as problem details
Errors cross three boundaries: from the domain to the use case, from the use case to a transport, and from the transport to a client. Two rules keep that safe and readable:
- Every layer shares five kinds, so a store, a use case and a handler agree on what “not found” means without importing each other.
- What a client reads is set on purpose. Wrapped context is for logs. A message reaches a client only when the code says it may.
The kinds
Section titled “The kinds”// The kinds. Test for them with errors.Is.var ( ErrInvalid = errors.New("invalid") ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict"))
// New returns an error of kind whose message is safe to show to clients.func New(kind error, message string) error { return &Error{Kind: kind, Message: message}}
// ValidationError reports which input fields are wrong and why. It is an// ErrInvalid, and both its fields and its messages are shown to clients.type ValidationError struct { Fields map[string]string}
// Invalid returns a ValidationError for one field.func Invalid(field, problem string) error { return &ValidationError{Fields: map[string]string{field: problem}}}Code wraps an error with context as it returns, fmt.Errorf("load user %s: %w", id, err), and tests the kind with errors.Is. The kind survives any amount of wrapping.
apperr.New attaches a message written for the client. The event store’s conflict error is one:
// ErrConcurrency means the stream changed after it was loaded. The caller// may reload and retry. It is an apperr.ErrConflict.var ErrConcurrency = apperr.New(apperr.ErrConflict, "this was changed by another request; reload and try again")
// Store keeps event streams.type Store interface { // Append adds records to the end of a stream as part of tx. Their // versions run from expectedVersion+1 up. When the stream's current // version isn't expectedVersion, Append writes nothing and returns // ErrConcurrency, including when a concurrent transaction appended the // same version first. Append(ctx context.Context, tx uow.Tx, streamID uuid.UUID, expectedVersion int, records []Record) error // Load returns a stream's committed records in version order, or none // for a stream that doesn't exist. Load(ctx context.Context, streamID uuid.UUID) ([]Record, error)}One table, one place
Section titled “One table, one place”Each transport maps kinds to its own codes in one table: httpx for HTTP, connectx for Connect. Handlers never pick a status code for an error themselves.
| Error | HTTP status |
|---|---|
*apperr.ValidationError, apperr.ErrInvalid |
400, with errors set for a validation error |
*http.MaxBytesError |
413 |
*httpx.TooManyRequestsError |
429, with Retry-After in seconds |
apperr.ErrUnauthorized |
401, with WWW-Authenticate: Bearer |
apperr.ErrForbidden |
403 |
apperr.ErrNotFound |
404 |
apperr.ErrConflict |
409 |
context.DeadlineExceeded |
503 |
context.Canceled |
nothing is written: the client left |
| anything else | 500, with no detail; the error text goes to the log |
Over Connect, connectx.Errors sends the same client-safe message with these codes:
| Error | Connect code |
|---|---|
apperr.ErrInvalid |
invalid_argument |
apperr.ErrUnauthorized |
unauthenticated |
apperr.ErrForbidden |
permission_denied |
apperr.ErrNotFound |
not_found |
apperr.ErrConflict |
aborted |
context.DeadlineExceeded |
deadline_exceeded |
context.Canceled |
canceled |
| anything else | internal, with no message |
On the calling side, connectx.FromConnect turns the codes back into kinds, and reads already_exists as a conflict too.
Problem details
Section titled “Problem details”HTTP errors are RFC 9457 application/, as both OpenAPI documents describe them:
Problem: type: object description: An RFC 9457 problem. required: [type, title, status] properties: type: { type: string } title: { type: string } status: { type: integer } detail: { type: string, description: Written for the user. } instance: { type: string } request_id: { type: string } errors: type: object description: What is wrong with each field of the request. additionalProperties: { type: string }Confirming an email change to an address another account took meanwhile answers:
{ "type": "about:blank", "title": "Conflict", "status": 409, "detail": "This email address belongs to another account.", "instance": "/v1/me/email-change/confirm", "request_id": "0192c1b5-7f3e-7a51-9d2c-4b1f3e8a6c10"}titleis the status text.detailis the client-safe message, omitted when there is none.request_idmatches theX-Request-Idresponse header and the request’s log line, so a user’s bug report leads straight to the logs.- A validation error adds
errors, an object from field name to problem. - A
5xxnever carrieserr.Error(). It might hold a SQL query, a hostname or worse.
The OAuth endpoints are the one exception. / and / answer in the RFC 6749 shape, {"error", "error_description"}, because that is what OAuth clients parse.