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:"-"`
}
MemberMeaning
typeURI identifying the problem type. Absent means about:blank: nothing beyond the status code.
statusThe net/http status code. The response carries the same one.
titleShort summary of the problem type, identical on every occurrence. Left empty it is serialised as the status phrase, which is what about:blank requires.
detailWhat happened this time, written for the client.
instanceURI identifying this occurrence: a request path, or an opaque id.
originExtension member: the failing call path, MyPackage.MyFunc.MyCall. For your operators.
solutionExtension member: how to fix it.
codeExtension 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.
retryableExtension 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.
currentExtension 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)
ConstructorStatusSlugRetryable
Invalid400invalid_requestfalse
NotFound404not_foundfalse
AlreadyExists(current, ...)409already_existsfalse
NotEmpty409folder_not_emptyfalse
Protected409protectedfalse
NotOnboarded422tenant_not_onboardedfalse
TooLarge413too_largefalse
UpstreamAuth502upstream_authfalse
Upstream(retryable, ...)502upstream_erroras given
Unavailable503upstream_unavailabletrue

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:

ArgumentResult
nil0
an *Error, however deeply wrappedits Code
any other error500, 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.