Skip to content

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:

  1. Every layer shares five kinds, so a store, a use case and a handler agree on what “not found” means without importing each other.
  2. 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.
internal/platform/apperr/apperr.go
// 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:

internal/platform/es/es.go
// 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)
}

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.

HTTP errors are RFC 9457 application/problem+json, as both OpenAPI documents describe them:

api/user.openapi.yaml
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"
}
  • title is the status text. detail is the client-safe message, omitted when there is none.
  • request_id matches the X-Request-Id response 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 5xx never carries err.Error(). It might hold a SQL query, a hostname or worse.

The OAuth endpoints are the one exception. /oauth/token and /oauth/revoke answer in the RFC 6749 shape, {"error", "error_description"}, because that is what OAuth clients parse.