errors
import infraerrors "github.com/infrapi/lib/pkg/errors"
One error type for everything a service hands back to an API client. Its JSON
form is an RFC 9457 problem detail,
served as application/problem+json.
type Error struct {
Type string `json:"type,omitempty"`
Code int `json:"status"`
Title string `json:"title"`
Message string `json:"detail"`
Instance string `json:"instance,omitempty"`
Origin string `json:"origin,omitempty"`
Solution string `json:"solution,omitempty"`
Slug string `json:"code,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
Current any `json:"current,omitempty"`
Err error `json:"-"`
}
| Member | Meaning |
|---|---|
type | URI identifying the problem type. Absent means about:blank: nothing beyond the status code. |
status | The net/http status code. The response carries the same one. |
title | Short summary of the problem type, identical on every occurrence. Left empty it is serialised as the status phrase, which is what about:blank requires. |
detail | What happened this time, written for the client. |
instance | URI identifying this occurrence: a request path, or an opaque id. |
origin | Extension member: the failing call path, MyPackage.MyFunc.MyCall. For your operators. |
solution | Extension member: how to fix it. |
code | Extension member: a stable, machine-readable code for the problem type, e.g. "already_exists". Unlike type it is never a URI - a client branches on it directly. |
retryable | Extension member: whether the same call may succeed later with no change to the request. A *bool so a bare New/Wrap can leave it unset (omitted) rather than implying “definitely not” - see the constructors below. |
current | Extension member: what exists already, on a 409 from AlreadyExists. |
Err is the wrapped cause. It is never serialised.
The common shapes#
For a malformed request, a missing resource, a conflict with what already
exists, or the system behind the API failing or not answering, use the
constructor that names it instead of New: it sets Slug and Retryable for
you.
return infraerrors.NotFound("no user with id %d", id)
return infraerrors.AlreadyExists(existing, "a user named %q already exists", name)
return infraerrors.Unavailable("the database could not be reached: %v", err)
| Constructor | Status | Slug | Retryable |
|---|---|---|---|
Invalid | 400 | invalid_request | false |
NotFound | 404 | not_found | false |
AlreadyExists(current, ...) | 409 | already_exists | false |
NotEmpty | 409 | folder_not_empty | false |
Protected | 409 | protected | false |
NotOnboarded | 422 | tenant_not_onboarded | false |
TooLarge | 413 | too_large | false |
UpstreamAuth | 502 | upstream_auth | false |
Upstream(retryable, ...) | 502 | upstream_error | as given |
Unavailable | 503 | upstream_unavailable | true |
A one-off error nobody else needs to branch on - New or Wrap - leaves
Slug unset and Retryable nil; both are then left out of the payload.
WithSlug, WithRetryable and WithCurrent chain on either, the same way
WithType and WithSolution do, for an ad hoc error that still needs one of
these:
return infraerrors.New(http.StatusServiceUnavailable, "relay.Forward",
"no replica is the leader right now; retry (%v)", err).
WithTitle("No leader").WithSlug("no_leader").WithRetryable(true)
Raising an error#
The second argument is the origin, not the title:
return infraerrors.New(http.StatusNotImplemented, "subscription.SetState",
"state %q not supported", state)
That is a complete problem detail already: no type, so about:blank, and the
title comes out as Not Implemented. Add the remediation when you know it:
return infraerrors.New(http.StatusNotFound, "user.Get", "no user with id %d", id).
WithSolution("list the users first")
A problem clients should be able to branch on deserves a type URI, with a title describing that type rather than this occurrence:
return infraerrors.New(http.StatusConflict, "subscription.Create", "id %q is taken", id).
WithType("https://infrapi.example.com/errors/duplicate-subscription", "Duplicate subscription")
Wrapping a cause#
Wrap keeps the original error reachable, so errors.Is and errors.As still
work several layers up:
cfg, err := config.NewConfig()
if err != nil {
return infraerrors.Wrap(err, http.StatusBadGateway, "config.Read", "")
}
An empty format takes the message of the cause as the detail. Pass one to say something more useful, or when the cause holds internals:
return infraerrors.Wrap(err, http.StatusBadGateway, "config.Read",
"unable to read %s", path)
Warning
Do not flatten the cause
New(code, origin, err.Error()) compiles and reads fine, but it turns the
cause into a string: every errors.Is above that point stops matching. Use
Wrap.
Finding an Error in a chain#
As is errors.As, re-exported so a caller just checking for an *Error never
needs to import the standard library errors package itself:
var apiErr *infraerrors.Error
if infraerrors.As(err, &apiErr) {
fmt.Println(apiErr.Code, apiErr.Message)
}
Reach for the standard library’s own errors.Is and errors.As directly when
matching something other than *Error - a sentinel like os.ErrNotExist, or a
foreign package’s own error type.
The printed form carries every member that is set, which is more than the payload shows:
infrapi-error: status=502 | title=Bad Gateway | origin=config.Read | detail=unable to read app.env | cause=open app.env: no such file or directory
cause= is left out when it is already the detail.
In a handler#
ErrorCode picks the status of any error, whoever produced it:
| Argument | Result |
|---|---|
nil | 0 |
an *Error, however deeply wrapped | its Code |
| any other error | 500, since nobody identified it |
ErrorCast gives the payload to serialise, synthesising an untyped 500 for a
foreign error while keeping its chain:
func handle(c *gin.Context) {
if err := doTheWork(); err != nil {
payload, _ := json.Marshal(infraerrors.ErrorCast(err))
c.Data(infraerrors.ErrorCode(err), infraerrors.MediaType, payload)
return
}
c.JSON(http.StatusOK, result)
}
c.Data rather than c.JSON, because the payload is served as
infraerrors.MediaType:
{
"type": "https://infrapi.example.com/errors/unknown-user",
"status": 404,
"title": "Unknown user",
"detail": "no user with id 42",
"instance": "/users/42",
"origin": "user.Get",
"solution": "list the users first"
}
Note
Nil in, nil out
ErrorCast(nil) returns a nil *Error. Never assign it straight to a
variable of type error: a nil pointer in an error interface is not nil.
Warning
What reaches the client
Err is json:"-", so the cause is never serialised. Its text still can
be: Wrap with an empty format copies err.Error() into detail, and
ErrorCast does the same for a foreign error. When the cause may carry a
path, a DSN or a hostname, pass an explicit format and let the real message
live in your logs.
examples/errors runs all of it, payloads included.
Note
Writing one to an HTTP response
This package only builds the value. Rendering it - application/problem+json,
the correlation id, logging an error nobody identified without leaking its
text to the caller - is server.WriteError/server.AbortError,
which is gin-aware; this package is not.