Error Handling¶
Overview¶
Every error has two audiences:
The user gets a short message, which describes what is wrong, together with a hint, which describes what to do about it. Implementation details are never reported to the user.
The log gets the complete error chain including the technical details, exactly once per failure, correlated with the request through a request ID.
The handling is implemented in three places: internal/domain provides the
error type, internal/util/response turns an error into an HTTP response and
logs it, and shared/api defines what a client receives.
What a client receives¶
An error response carries the message, the HTTP status code and metadata:
{
"type": "error",
"error_code": 400,
"error": "Server \"server01\" is a member of cluster \"one\" and can not be deleted",
"metadata": {
"reason": "server_is_cluster_member",
"hint": "Remove the server from the cluster first.",
"details": {"server": "server01", "cluster": "one"},
"request_id": "3f1c1a4e-0a0e-4a5f-9a1e-2d7b8c9e0f11"
}
}
The same request ID is reported in the X-Request-Id response header and with
every log record of the request.
Clients report the message and the hint, they do not invent guidance of their own:
$ operations-center provisioning server remove server01
Error: Server "server01" is a member of cluster "one" and can not be deleted
Hint: Remove the server from the cluster first.
Errors caused by the user¶
Report them with domain.NewErrorf, so the message reaches the user unaltered:
return domain.NewErrorf(domain.ErrOperationNotPermitted, api.ErrorReasonServerIsClusterMember,
"Server %q is a member of cluster %q and can not be deleted", name, cluster).
WithHintf("Remove the server from the cluster first.").
WithDetail("server", name).
WithDetail("cluster", cluster)
Kind: classifies the error and decides the status code. It is matched with
errors.Is, soerrors.Is(err, domain.ErrOperationNotPermitted)keeps working through wrapping.Message: what is wrong. It is self-contained, so it does not rely on the context added by other layers, names are quoted with
%q, it is capitalized and carries no trailing period.Reason: optional, see Reasons.
Hint: optional, see Hints.
Details: the dynamic values of the message in machine readable form.
Cause:
WithCauseattaches the technical error. It is reported in the log and never to the user.
domain.NewValidationErrf stays the short form for the validation of input.
The status code is derived from the kind:
Kind |
Status |
|---|---|
|
400 Bad Request |
|
400 Bad Request |
|
400 Bad Request |
|
404 Not Found |
|
401 Unauthorized |
|
403 Forbidden |
|
503 Service Unavailable |
Error of an Incus server |
502 Bad Gateway |
everything else |
500 Internal Server Error |
Errors not caused by the user¶
Everything, that is not classified, is an internal error. The user only gets
Internal server error, see the Operations Center log for details together with
the request ID, the error itself stays in the log. Such errors only need enough
context to be understood in the log:
return fmt.Errorf("Failed to fetch servers of cluster %q: %w", name, err)
Wrapping¶
Wrap with
%w, never with%v, so the kind of the error survives.Only wrap, if the wrap adds context, which is not there yet. Context, that an inner layer already carries, is not repeated.
A
Failed to <operation>prefix of a handler or a service is for the log only. It is not reported to the user, as long as the error is classified, so it never has to carry the message the user needs.
The message for the user is picked by domain.UserMessage: it returns the
message of the outermost domain.Error of the chain, or, for an error, that is
only classified by a wrapped kind, the message of the chain without the text of
the kind.
Reasons¶
A reason is a stable, machine readable identifier for a concrete error
condition, declared in shared/api/error.go in lower snake case, e.g.
server_not_evacuated. Clients branch on it, so it never changes once it is
released.
Add a reason only, if a client needs to tell the condition apart from other
errors of the same kind, e.g. to offer an action. Without a reason, the generic
reason of the kind is reported, e.g. not_found.
Hints¶
A hint tells the user how to resolve the error. It is part of the response, so every client reports the same guidance:
Write it as a full sentence with a trailing period.
Keep it client agnostic.
Evacuate the server first or use the force option.works for the CLI, the UI and any other client,Run operations-center …does not.Name the option, which lifts the restriction, if there is one.
Client specific guidance, e.g. a concrete command or a button, is mapped by the client from the reason. Prose is never duplicated there.
Logging¶
Log or return, never both. An error, that is returned to the caller, is logged by the boundary it ends up at.
The boundaries are the response middleware (
internal/util/response), which logs one record per failed request, the background tasks, the authentication middleware and the goroutines, which have no caller left to report to.Errors are logged with
logger.Err, so they always use theerrkey.The decorators generated for the services, repositories and ports only trace returned errors at debug level. Raise the level of the component to see them, see
settings.log_levels.
A record of a failed request carries the message reported to the user
(response), the reason, the details and the complete chain (err):
WRN Request failed method=DELETE request_uri=/1.0/provisioning/servers/server01
status_code=400 response="Server \"server01\" is a member of cluster \"one\" and can not be deleted"
reason=server_is_cluster_member details="map[cluster:one server:server01]"
err="Failed to delete server \"server01\": Server \"server01\" is a member of cluster \"one\" and can not be deleted"
component=api request_id=3f1c1a4e-0a0e-4a5f-9a1e-2d7b8c9e0f11
Errors recorded on entities and warnings¶
Fields, which keep an error for the user, e.g. the last error of a deployment or
of a cluster update, store domain.UserMessage(err). The technical details are
logged where the error is recorded.
A warning, which reports an error, is created with
warning.NewWarningFromError. It stores the message for the user and lets
Emit log the error itself:
s.warning.Emit(ctx, warning.NewWarningFromError(api.WarningTypeUnreachable, scope, err, "Server is unreachable"))
Testing¶
Assert on the kind, the reason and the hint, not on the prose of a message, so
an improved message does not break the tests. The helpers are in
internal/util/testing/errassert:
assertErr: func(tt require.TestingT, err error, a ...any) {
errassert.DomainError(domain.ErrOperationNotPermitted, api.ErrorReasonServerNotEvacuated)(tt, err, a...)
errassert.HintIs("Evacuate the server first or use the force option.")(tt, err, a...)
},
errassert.DomainError(kind, reason)asserts the kind and the reason at once, which is what a client reacts to.errassert.ReasonIsanderrassert.HintIsassert one of the two on their own.errassert.UserMessageContainsasserts on the message, where the prose is the point of the test, e.g. where two conditions are told apart. Use the shortest fragment, which distinguishes them, not the whole sentence.
Enforcement¶
make lint runs cmd/domain-errors, which reports the places, that do not
follow these conventions, and fails the build. Run it on its own with:
go run ./cmd/domain-errors ./...
It resolves the kinds and the reasons through the type information, so it sees
them through an import alias and through a reference inside the domain package
itself.
Layers¶
Two rules depend on which layer the code belongs to, derived from the packages themselves:
Infrastructure is a package below a
repoor anadapterdirectory, and everything underinternal/adapterandinternal/sql. It talks to the database, the file system or another server.Business logic is a package holding a
*_service.go. That covers the files next to the service as well, e.g.server_deployment.go, and a new service package is in scope without anybody adding it anywhere.
Every other package is left alone. The remaining rules only fire where a kind is referenced or a user facing error is built, so a package, which does neither, never produces a finding.
Rules¶
Rule |
What it reports |
|---|---|
|
|
|
A kind is returned as the error itself, outside the infrastructure layer. It carries no message, so the user reads the text of the kind alone, e.g. |
|
An error, which originates in the business logic, carries no kind, so nothing says whether it is meant for the user. A wrap is not reported, it inherits the kind of what it wraps, and neither is an error handed to |
|
|
|
The message does not read the way a message for the user is meant to read. |
|
The hint is not a full sentence. |
|
The hint names one particular client, although it is reported to every client. |
|
The reason is not an |
|
A detail is not named the way the log names its attributes. |
|
|
|
A reason is declared in |
|
|
Directives¶
An error, which is internal by design, e.g. a violated invariant the user can do nothing about, stays unclassified and says so. It is reported as an internal server error, which is what it is:
//domain-errors:internal Programmer error, the BMC API type is not handled.
return fmt.Errorf("Failed to get BMC server client for type %q", server.BMCConfig.APIType)
A place, which is right the way it is although a rule says otherwise, carries the general escape hatch instead, which says why:
//domain-errors:ignore The kind is attached for the caller, which classifies it.
return fmt.Errorf("...: %w", domain.ErrNotFound)