Error handling is where Iris differs most visibly from a bare net/http
application. Instead of scattering ctx.StatusCode and ad-hoc JSON bodies
across handlers, the rest/errors package gives you canonical error codes,
a single wire format, and one central place where Go errors are translated
into HTTP responses. Your domain packages return plain sentinel errors; your
handlers forward them; the framework classifies, logs, and renders them.
This chapter covers the whole system: the wire format, the code catalog, the
direct senders, the central error map, field validation, private errors,
driver-specific resolvers, error-returning handlers, and the HTML and
RFC 7807 renderers.
Background
Errors Are Values, Not Exceptions
Go has no exceptions and no try/catch. A function that can fail returns
an error (an ordinary value) as its last result, and the caller
checks it with if err != nil. Nothing unwinds the stack, and an ignored
error is simply gone. Two standard-library idioms shape everything in
this chapter. A sentinel error is a package-level value created once with
errors.New and compared by identity: store.ErrNotFound is the
not-found error of that package. Wrapping adds context while keeping the
original recognizable: fmt.Errorf("shelf 42: %w", err) produces a new
error that still contains the sentinel, and errors.Is walks the chain
of wrapped errors to find it (errors.As does the same for typed
errors). That is why the central map matches an error no matter how many
layers of context were added on the way up.
Go reserves panic for a different category: programmer bugs and
unrecoverable states, not expected failures. A well-behaved server
recovers from panics so one broken request cannot crash the process.
Iris does this for you and routes the panic through the same error
pipeline as any other failure.
Why APIs Need One Error Format
The consumer of your API is a program, not a person: browser JavaScript, a mobile app, another service. When a request fails, that program has to decide what to do: show a message under the right form field, send the user to the login page, retry in a few seconds, or give up. It can only decide mechanically if every endpoint reports failure the same way: one JSON shape, with a machine-readable code, documented once. Without that contract every client accumulates per-endpoint special cases, and every error you add breaks someone. The wire format in this chapter is that contract, and the problem that disappears from your handlers is deciding, at each call site, what an error response should look like.
Client Mistakes (4xx) versus Server Failures (5xx)
HTTP divides failure status codes into two families, and the split carries real meaning (RFC 9110). A 4xx status says the request is wrong: the input failed validation (400), the caller is not signed in (401) or not allowed (403), the resource does not exist (404). The client must change something; repeating the same request will fail the same way. A 5xx status says the server failed: a bug, a crashed dependency, an overloaded database. The client did nothing wrong and may retry later. Everything downstream keys off this split (monitoring should page a human for 5xx spikes but not for 404 noise, clients retry a 503 but never a 400), which is why the code catalog below forces every error into one family or the other.
Why Leaking Internals Is a Security Problem
Raw error text is written for the developer who owns the code, and it
shows: SQL fragments, table and column names, file paths, internal
hostnames, driver versions. Sent to a client, each of those is free
reconnaissance for an attacker mapping your system, the class of flaw
security guides call information disclosure. The safe pattern, applied
throughout this chapter, is asymmetry: the client receives a stable,
generic message plus a unique error id, while the full error text goes
to the server-side logs where that id lets you find it. The Private,
ErrPrivate and LogErr tools exist to make this asymmetry the path of
least resistance.
The Philosophy: One Place for Errors
The design has three rules:
- The domain defines sentinel errors. A service or repository returns
store.ErrNotFound,store.ErrShelfLocked. These are plainerrors.Newvalues that know nothing about HTTP. - The application maps them once. A single
errors.NewOptions()value, handed to the builder’sErrorsstep, associates each sentinel with a canonical code.errors.Isdoes the matching, so wrapping withfmt.Errorf("...: %w", err)anywhere in the call chain never breaks it. - Handlers just forward.
ctx.OK,errors.HandleErrorand friends send any non-nil error through the same pipeline. A handler never decides status codes for domain failures.
// store/store.go
package store
import "errors"
var (
ErrNotFound = errors.New("store: not found")
ErrDuplicate = errors.New("store: already exists")
ErrShelfLocked = errors.New("store: shelf locked")
)// main.go (excerpt)
app := iris.NewBuilder().
Prefix("/api").
AllowOrigin("none").
Compression(true).
LogRequests(true).
Health(true, "production", "admin").
Errors(errors.NewOptions().
MapErrors(errors.NotFound, store.ErrNotFound).
MapErrors(errors.AlreadyExists, store.ErrDuplicate).
MapErrors(errors.PermissionDenied, store.ErrShelfLocked)).
Services( /* repositories, services ... */ ).
RouterMiddlewares().
Middlewares().
API("/books", NewBooksAPI).
Build()(The chain order is compiler-enforced and every step appears exactly once;
Errors sits between Health and Services. See
Chapter 3.)
// api/books.go (excerpt)
func (api *BooksAPI) get(ctx iris.Context) {
book, err := api.svc.Book(ctx, ctx.Params().GetString("id"))
ctx.OK(book, err)
}If the service returns store.ErrNotFound (even wrapped three levels
deep), the client receives a 404 NOT_FOUND wire error. Unmapped errors
become a generic 500 INTERNAL: the message sent to the client is a
constant, the real error is logged server-side. Clients never see your SQL.
The Wire Format
Every error response shares one JSON structure, the “HTTP wire error”
(errors.Error):
{
"id": "3e9c1b6e-8a3e-4a3c-9d7a-2f2c1a6b5e4d",
"http_error_code": {
"canonical_name": "NOT_FOUND",
"status": 404
},
"message": "store: not found",
"detail": "no book with id '42'",
"validation": [
{"field": "title", "value": "", "reason": "must not be empty"}
],
"data": {"hint": "list books at /api/books"}
}id: unique identifier per occurrence, a UUID by default. Use it to correlate a client report with server logs.http_error_code: the canonical name and its HTTP status.message: short, human-readable summary.detail(optional): explanation specific to this occurrence.validation(optional): array of field-level failures.data(optional): structured extra data, any JSON value.
(A UUID, or universally unique identifier, is a 128-bit random value with a standard 36-character text form. Collisions are so improbable that one can be generated anywhere, with no coordination, which makes it a good per-occurrence tag.)
The struct also carries optional type and instance fields for RFC 7807
problem details, covered below.
Canonical Error Codes
An errors.ErrorCodeName couples a stable, machine-readable name with an
HTTP status code. The most used ones:
| Constant | Canonical name | Status |
|---|---|---|
errors.InvalidArgument | INVALID_ARGUMENT | 400 |
errors.FailedPrecondition | FAILED_PRECONDITION | 400 |
errors.OutOfRange | OUT_OF_RANGE | 400 |
errors.Unauthenticated | UNAUTHENTICATED | 401 |
errors.PaymentRequired | PAYMENT_REQUIRED | 402 |
errors.PermissionDenied | PERMISSION_DENIED | 403 |
errors.NotFound | NOT_FOUND | 404 |
errors.MethodNotAllowed | METHOD_NOT_ALLOWED | 405 |
errors.AlreadyExists | ALREADY_EXISTS | 409 |
errors.Aborted | ABORTED | 409 |
errors.TooLarge | TOO_LARGE | 413 |
errors.UnsupportedMediaType | UNSUPPORTED_MEDIA_TYPE | 415 |
errors.UnprocessableEntity | UNPROCESSABLE_ENTITY | 422 |
errors.ResourceExhausted | RESOURCE_EXHAUSTED | 429 |
errors.Internal | INTERNAL | 500 |
errors.DataLoss | DATA_LOSS | 500 |
errors.Unknown | UNKNOWN | 500 |
errors.Unimplemented | UNIMPLEMENTED | 501 |
errors.BadGateway | BAD_GATEWAY | 502 |
errors.Unavailable | UNAVAILABLE | 503 |
errors.DeadlineExceeded | DEADLINE_EXCEEDED | 504 |
The package registers a constant for effectively every HTTP failure status
(406–511), plus a few Iris-specific ones such as errors.InvalidToken (498)
and errors.TokenRequired (499). Register your own during initialization
when the built-in vocabulary is not enough:
import (
"net/http"
"github.com/kataras/iris/v14/rest/errors"
)
// UserBlocked renders as 403 with canonical name USER_BLOCKED.
var UserBlocked = errors.Register("USER_BLOCKED", http.StatusForbidden)Registration is not thread-safe by design; do it in init or early in
main, before the server starts.
Sending Errors Directly
Every code is also a sender. Each method below writes the wire error and
stops the request, except Wrap, which only tags an error for later
processing:
| Method | Signature (after the code) | Purpose |
|---|---|---|
Message | (ctx, message string) | Message only |
Messagef | (ctx, format string, args ...any) | Formatted message |
Detail | (ctx, msg, detail string) | Message + detail |
Detailf | (ctx, msg, detail string, args ...any) | Formatted detail |
Data | (ctx, msg string, data any) | Message + JSON data |
DataWithDetail | (ctx, msg, detail string, data any) | All three |
Validation | (ctx, errs ...ValidationError) | Field failures |
Err | (ctx, err error) | err.Error() as the message |
ErrPrivate | (ctx, err error) | Generic message, error kept server-side |
LogErr | (ctx, err error) | Logs; Internal sends a generic message |
Log | (ctx, format string, args ...any) | Like LogErr with formatting |
Problem | (ctx, title, detail, typeURI, instanceURI string, extra ...any) | RFC 7807 |
Wrap | (err error) error | Tags an error, sends nothing |
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func handler(ctx iris.Context) {
// Message and detail.
errors.NotFound.Detailf(ctx, "book not found",
"no book with id %q", ctx.Params().GetString("id"))
// Structured data alongside the message.
errors.FailedPrecondition.Data(ctx, "insufficient funds", iris.Map{
"current_balance": 50.00,
"required_balance": 100.00,
})
}One sentence gets written often enough to have its own function.
errors.NotFoundID(ctx, resource, id) renders NOT_FOUND with the detail
"<resource> <id> not found", which is the Detailf above with its
format string spelled out for the hundredth time:
func getPlan(ctx iris.Context) {
plan, err := svc.GetPlan(ctx, id)
if errors.HandleError(ctx, err) {
return
}
if plan == nil {
errors.NotFoundID(ctx, "plan", id)
return
}
ctx.OK(plan, nil)
}The id is formatted with %v, so a string, an integer and a uuid.UUID
all read the way they print. Its bool result follows the Context.OK
convention, true meaning the handler succeeded, so it is always false
here; note that HandleError in the same package reports the opposite,
true when it wrote the response, which is why the example tests one and
ignores the other.
Err, LogErr and ErrPrivate
The three error-value senders differ in what the client sees and what is logged:
// Client sees err.Error() as the message under the given code.
// Validation errors are auto-routed to Validation().
errors.InvalidArgument.Err(ctx, err)
// Logs the error. For errors.Internal the client gets a generic
// message; for any other code the error text is sent.
errors.Internal.LogErr(ctx, err)
// The client always gets the generic message
// ("server error" / "an unexpected error occurred while
// processing the request"); the real error stays server-side.
errors.Internal.ErrPrivate(ctx, err)Use Err for errors that are safe to show, LogErr as the default for
server faults, and ErrPrivate when the error text may leak schema, paths
or credentials. Log and LogErr also skip canceled requests: when the
client has disconnected, nothing is logged or sent (package-level setting
errors.SkipCanceled, enabled by default).
Err has one more convenience: if the error value is a struct, its exported
fields are marshaled into the response’s data member automatically, so a
typed domain error carries its context to the client without extra code.
Wrap: tag now, send later
Wrap joins the code with an error so a lower layer can decide the status
without touching the context:
func (s *Service) Publish(ctx context.Context, id string) error {
n, err := s.repo.Publish(ctx, id)
if err != nil {
return err
}
if n == 0 {
return errors.NotFound.Wrap(fmt.Errorf("book %q", id))
}
return nil
}When this error later reaches errors.HandleError, the tag is detected and a
404 NOT_FOUND with message book "42" is sent. Prefer the central map for
recurring cases; reach for Wrap when the classification depends on local
information the map cannot see.
The Central Error Map
errors.NewOptions() returns an *errors.Options whose error-code map is
not empty. The defaults (built in rest/errors/options.go) map:
database/sql.ErrNoRows→NotFound- the
github.com/kataras/jwttoken errors (jwt.ErrExpired,jwt.ErrMissing,jwt.ErrTokenSignature,jwt.ErrBlockedand the rest of the family) →Unauthenticated view.ErrNoEngine(frommiddleware/view) →Internal- hex-decode failures (
encoding/hex: invalid byte) →InvalidArgument, re-worded as “invalid secret format”
PostgreSQL mappings are deliberately not included; they are opt-in through
the pgerrors subpackage.
Every Map... call extends the map; nothing is replaced:
import (
"fmt"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
"myapp/store"
)
opts := errors.NewOptions().
// Sentinel values, matched with errors.Is (wrapping-safe).
MapErrors(errors.NotFound, store.ErrNotFound).
MapErrors(errors.AlreadyExists, store.ErrDuplicate).
// Whole types, matched with errors.As semantics.
MapErrorTypes(errors.InvalidArgument, store.QuotaError{}).
// A function: return nil to pass, or the error to send.
MapErrorFunc(errors.Internal, func(err error) error {
if store.IsRetryable(err) {
return errors.Private(err) // hide the cause from the client
}
return nil
}).
// Context-aware variant: enrich with request detail.
MapErrorFuncCtx(errors.NotFound,
func(ctx iris.Context, err error) error {
if errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("%s: %w", ctx.Path(), err)
}
return nil
})MapErrors(code, targets ...error): exact sentinels viaerrors.Is.MapErrorTypes(code, targets ...any): concrete or interface types; pass a typed nil pointer ((*MyError)(nil)) for interfaces.MapErrorFunc(code, func(error) error): transform or claim errors; functions for the same code run in registration order until one returns non-nil. The two closure bodies people write most often,errors.Ason a concrete type and a substring test on the message, have their own calls; see the next section before reaching for this one.MapErrorFuncCtx(code, func(iris.Context, error) error): the same, with access to the request context. The context is nil when the map’sFilter(ctx, err)method is called directly outside a request, so tolerate nil if you call it yourself.
Candidates are evaluated in ascending status-code order, so more specific client-error classifications win over catch-all 500 mappings.
Mapping a typed error, and mapping a text
Two shapes of MapErrorFunc closure are common enough to have their own
call. The first finds a concrete error type and builds the response from
its fields; without a helper it is a closure declaring a variable, calling
errors.As, and returning nil on the way out.
opts.MapErrorAs(errors.ResourceExhausted,
func(e *store.QuotaError) *errors.Error {
return &errors.Error{
Message: "quota exceeded",
Detail: fmt.Sprintf("the limit is %d", e.Limit),
}
})errors.As looks for the type anywhere in the error tree, so a wrapped
one matches, and the render function receives the value it found. A nil
result means “not mine” and the next mapping gets a turn, exactly like
MapErrorFunc returning nil. Passing a nil render classifies without
transforming, which needs the type spelled out because there is nothing
left to infer it from: opts.MapErrorAs[*store.QuotaError](code, nil).
MapErrorAs is a generic method on both *errors.Options and a bare
errors.ErrorCodeMap, and it returns the receiver, so it chains after
MapErrors like every other mapping call. The type parameter is
inferred from the render function. When you need the same lookup
inside your own code, errors.AsType[*store.QuotaError](err) is the
standard library’s errors.AsType, forwarded by rest/errors next to
Is and As, so a file that imports the package as errors needs no
second import.
The second shape sniffs a substring, which is all a driver leaves you when its error arrives as text:
opts.MapErrorContains(errors.InvalidArgument,
"violates check constraint", "invalid value")The match is case-sensitive and the message replaces the original text, so
the driver’s own words never reach the client. Two caveats come with it.
Prefer a typed mapping wherever one exists, because a driver is free to
reword its text in the next release and a substring mapping fails silently
when it does. And because the replacement discards the original, log
before you map when those words are the only diagnostic you have, the way
pgerrors does with errors.LogError. An empty substr registers
nothing at all rather than a mapping that matches every error: a missing
constant is a mistake, and the safe reading of it is “map nothing”.
Wiring the options
With the builder, pass the options to the Errors step (its position in
the chain is compiler-enforced):
iris.NewBuilder().
// ... Prefix, AllowOrigin, Compression, LogRequests, Health ...
Errors(opts).
// ... Services, RouterMiddlewares, Middlewares, API, Build ...With a manual iris.New() application, register the options’ middleware
yourself, before the routes it should cover:
app := iris.New()
app.UseRouter(opts.Handler())opts.Handler() stores the options on each request’s context; every sender
and HandleError call then finds the map, logger, collector and renderer
you configured. UseRouter makes it cover 404/405 paths too; plain
app.Use(opts.Handler()) covers matched routes only (see
Chapter 4 for the middleware families).
Forwarding Errors from Handlers
errors.HandleError(ctx, err) bool, from rest/errors, is the
front door of the pipeline. It returns false for a nil error, otherwise it
classifies the error, writes the response and returns true:
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func (api *BooksAPI) create(ctx iris.Context) {
input, ok := ctx.BindJSON[CreateBookInput]()
if !ok {
return // error response already written
}
book, err := api.svc.Create(ctx, input)
if errors.HandleError(ctx, err) {
return
}
ctx.Created(book, nil)
}ctx.Created(book, err) would fold those three middle lines into itself,
which is why handlers built on the response helpers rarely name
HandleError. Call it directly for an error that arrives outside a
helper’s reach: from a guard, from a middleware, from a stream already in
flight.
ctx.HandleError(err) is the same thing as a context method, for a step
that has an error but nothing to render on success:
func (api *PlansAPI) deactivate(ctx iris.Context) {
if ctx.HandleError(api.svc.DeactivatePlans(ctx, id)) {
return
}
ctx.NoContent(true, nil)
}It is the error half that OK, Created, NoContent, List, Count
and Paginated each run before rendering their success body, on its own.
A handler whose only use of rest/errors was that one call no longer
imports the package.
When the failure of one step means something specific at that call site
and nothing in general, name the code instead of mapping it.
errors.<Code>.Handle(ctx, err) renders the error as that code and
reports whether it did:
func (api *AuthAPI) changeEmail(ctx iris.Context) {
input, ok := ctx.BindJSON[ChangeEmailInput]()
if !ok {
return
}
if ctx.HandleError(api.identity.ChangeEmail(ctx, input)) {
return
}
// A third party being down is Unavailable here, whatever the
// central map would have made of it.
if errors.Unavailable.Handle(ctx, api.users.UpdateEmail(ctx, input.Old, input.New)) {
return
}
ctx.NoContent(true, nil)
}Handle is Err plus the answer to “did it fire”, and a nil error writes
nothing and reports false. Err keeps the signature it has, because
func(*iris.Context, error) is what LogErrorFunc and
cors.HandleErrorFunc accept and
cors.New().HandleErrorFunc(errors.FailedPrecondition.Err) passes it
straight to them.
The classification order inside HandleError is a waterfall: the first
test that recognizes the error writes the response, so a specific rule
always beats a general one.
The family has three siblings:
errors.HandleRequestError(ctx, err): identical, except that it treats a failure to decode the request body as the client’s fault. It differs at step 6 only: a JSON decoding error renders400 INVALID_ARGUMENT(“unable to parse request body”) rather than a 500. An error that no step recognizes still ends at500 INTERNALeither way. Use it when processing user input, where a decode failure is almost certainly the client’s.errors.HandleErrorf(ctx, err, format, args...): wraps the error with call-site detail (using%w, so mappings still apply), then handles it:
// Renders the mapped 404 with message "shelf 42: store: not found".
if err := api.svc.Rename(ctx, id, input.Title); err != nil {
errors.HandleErrorf(ctx, err, "shelf %s", id)
return
}errors.HandleRequestErrorf: the same enrichment with the 400 fallback.
The response helpers all call HandleError internally: ctx.OK,
ctx.Created, ctx.NoContent, ctx.Count and ctx.Paginated
(Chapter 6). That is why a typical handler body
is one line: ctx.OK(result, err).
Field Validation
errors.ValidationError is a small interface (error plus
GetReason() string), and errors.ValidationErrors is a slice of them.
The rest/errors/validation subpackage ships fluent, generic builders:
| Constructor | Chainable rules |
|---|---|
validation.String(name, value) | NotEmpty(), Length(min, max), Email(), Fullname(), Password(level), Phone(kind) |
validation.Number(name, value) | Positive(), Negative(), Zero(), NonZero(), InRange(min, max) |
validation.Slice(name, value) | NotEmpty(), Length(min, max) |
validation.Field(name, value) | Func(fns ...func(T) string), custom rules |
Rules accumulate reasons; validation.Join filters out the fields that
passed and returns nil when everything is valid. The idiomatic v14 place
for this is a Validate method on the request type; ctx.BindJSON[T]()
calls it automatically (Chapter 5):
import (
"github.com/kataras/iris/v14/rest/errors/validation"
)
type CreateBookInput struct {
Title string `json:"title"`
Pages int `json:"pages"`
Email string `json:"email"`
}
func (in *CreateBookInput) Validate() error {
return validation.Join(
validation.String("title", in.Title).NotEmpty().Length(1, 200),
validation.Number("pages", in.Pages).Positive(),
validation.String("email", in.Email).Email(),
)
}A failed validation renders with a fixed message and detail and one entry per failing field. This is the real wire format:
{
"http_error_code": {
"canonical_name": "INVALID_ARGUMENT",
"status": 400
},
"message": "validation failure",
"detail": "fields were invalid",
"validation": [
{"field": "title", "value": "",
"reason": "must not be empty, must be between 1 and 200 characters"},
{"field": "pages", "value": 0, "reason": "must be positive"},
{"field": "email", "value": "bad",
"reason": "must be a valid email address"}
]
}Outside Validate methods, send field errors explicitly, either through
Err (validation errors are auto-routed) or Validation directly:
name := validation.String("name", input.Name).NotEmpty().Length(3, 50)
email := validation.String("email", input.Email).Email()
if err := validation.Join(name, email); err != nil {
errors.InvalidArgument.Err(ctx, err)
return
}Custom rules are plain functions from the value to a reason string (empty means valid):
username := validation.Field("username", input.Username).Func(
validation.NotEmpty,
func(value string) string {
if strings.Contains(value, " ") {
return "must not contain spaces"
}
return ""
},
)
if err := username.Get(); err != nil { // Get() returns nil when valid
errors.InvalidArgument.Err(ctx, err)
return
}Any type of your own that implements Error() string and
GetReason() string participates in the same machinery, useful when
adapting a third-party validator’s output.
Private Errors
Two complementary tools keep sensitive error text away from clients:
errors.Private(err)marks an error at its creation site. Wherever it later surfaces in the pipeline, the client receives the generic message while the original text is preserved for server-side logging. Use it in service layers that cannot know how the error will be rendered, or insideMapErrorFunctransformations.code.ErrPrivate(ctx, err)decides at the response site: generic message now, real error kept server-side. That holds for every error type: a*errors.Erroror a validation error gets the generic message here too.
// Service layer: mark once, forget about it.
return errors.Private(
fmt.Errorf("query failed on host %s: %w", host, err))
// Handler: forwards as usual; the marker is detected automatically.
errors.HandleError(ctx, err)The scrubbed response is always the same shape:
{
"http_error_code": {"canonical_name": "INTERNAL", "status": 500},
"message": "server error",
"detail": "an unexpected error occurred while processing the request"
}That is the shape HandleError and the senders produce.
errors.Handler answers a private stored error by the status it
carries, and it does so before any mapping is asked. Below 500 the
client reads the status text and no detail: a private error stored with
ctx.StopWithError(401, iris.PrivateError(err)) reads
"message":"Unauthorized", not a server fault. At 500 and above it
reads the generic “server error”. The status the handler set stays, even
when your map would have turned the error into a 404. The jwt
middleware stops every bad token this way, so its 401 reads
Unauthorized with or without your error options, and a client cannot
learn from the body why its token was refused. The private text still
reaches the collector.
Note the asymmetry worth memorizing: errors.Internal.LogErr(ctx, err) is
generic to the client, but errors.Internal.Err(ctx, err) sends the error
text. When in doubt on a 500 path, use LogErr.
Database Errors and Resolvers
sql.ErrNoRows is mapped out of the box. PostgreSQL mappings for the
github.com/kataras/pg driver are opt-in, so the driver stays out of your
dependency graph unless you use it:
import _ "github.com/kataras/iris/v14/rest/errors/pgerrors"(A blank import, the _ alias, compiles a package purely for its side
effects: its init functions run, registering the resolvers below, and
no identifier enters your namespace.)
The blank import restores four behaviors inside HandleError:
pg.ErrNoRows→404 NOT_FOUND(“resource not found”)- duplicate-key violations →
409 ALREADY_EXISTSwith a generic client detail; the raw driver error (which leaks table, column and constraint names) is logged server-side only - foreign-key violations (SQLSTATE
23503) →400 INVALID_ARGUMENT(“invalid reference”), naming the constraint when the driver reports one so the client can tell which reference was wrong. A row that points at something which is not there is bad input, not a server fault, and without this branch it fell through to a 500. The driver’s own message and detail, which name the table, the column and the offending value, are logged and never forwarded - SQLSTATE-tagged input errors (“invalid input syntax”, “operator does not
exist”) →
400 INVALID_ARGUMENTwith the SQLSTATE noise trimmed
(SQLSTATE is the SQL standard’s five-character error code, attached to
every PostgreSQL error: 23505 is a unique-key violation, 22P02 an
invalid text representation. The resolver reads it to tell client-caused
input errors apart from genuine server faults.)
The subpackage is built on errors.RegisterErrorResolver, a public
extension point you can use for any driver or SDK. Resolvers run inside
HandleError after the per-route options map and before the built-in
classification; the first one that reports true stops the chain:
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func init() {
errors.RegisterErrorResolver(
func(ctx iris.Context, err error) bool {
var terr *mydriver.TimeoutError
if !errors.As(err, &terr) {
return false // not ours, keep going
}
errors.Unavailable.Message(ctx,
"storage temporarily unavailable")
return true
})
}Register resolvers at init time or before serving, like error codes.
Handlers That Return Errors
Writing if err != nil { errors.HandleError(ctx, err); return } in every
handler gets repetitive. The errors.Router wrapper accepts handlers of
type errors.Func (func(ctx iris.Context) error) and pipes any
returned error through HandleError automatically. A controller opts in
by taking *errors.Router in its Routing method; the framework’s
controller resolver recognizes it (plain routing functions
func(r *errors.Router) work too):
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
"myapp/reports"
)
type ReportsAPI struct {
svc *reports.Service
}
func NewReportsAPI(svc *reports.Service) *ReportsAPI {
return &ReportsAPI{svc: svc}
}
func (api *ReportsAPI) Routing(r *errors.Router) {
r.Get("/{year:int}", api.byYear)
}
func (api *ReportsAPI) byYear(ctx iris.Context) error {
report, err := api.svc.ByYear(ctx, ctx.Params().GetInt("year"))
if err != nil {
return err // classified by the central map
}
return ctx.JSON(report)
}Register it like any controller: API("/reports", NewReportsAPI) in the
builder, or app.Router("/reports", NewReportsAPI(svc)) manually. The
wrapper exposes the full routing surface
(Get, Post, Put, Delete, Patch, Any, Subdomain, Use,
UseRouter) with error-returning signatures. Middleware follows the same
rule: a nil return continues the chain, a non-nil error is handled and
stops it:
r.Use(func(ctx iris.Context) error {
if api.svc.Rebuilding(ctx) {
return errors.Unavailable.Wrap(
errors.New("reports are being rebuilt"))
}
return nil
})Write these for the rules only your code knows. Authentication is not one
of them: a machine caller presenting a shared secret is apikey.New from
middleware/apikey (Chapter 12),
which renders the 401 through this same pipeline.
For a single handler on a plain router, errors.FuncHandler(fn) converts
an errors.Func to a standard handler, and errors.FuncMiddleware(fn)
does the same with ctx.Next() semantics.
HTML and RFC 7807 Rendering
JSON is the default rendering, with two built-in alternatives.
HTML error pages
When the request or the already-set response Content-Type is text/html,
wire errors render as HTML pages automatically. The errors.HTML renderer
looks for a view template named after the status code (errors/404.html,
errors/500.html) using the view engine registered through
middleware/view (Chapter 6); if no engine or
template exists, a minimal inline page with the status, canonical name and
message is served. To force HTML for every error regardless of content
type:
opts := errors.NewOptions().SetRenderer(errors.HTML)Inside the template, the wire error is the root value: {{.Message}},
{{.Detail}}, {{.ErrorCode.Status}}, {{.ID}}.
RFC 7807 problem details
RFC 7807, “Problem Details for HTTP APIs”, is the IETF’s standard answer
to the format question this chapter opened with: a small, extensible
JSON vocabulary (type, title, status, detail, instance)
served under its own media type so generic clients and tooling can
recognize an error response without knowing your API. Reach for it when
you must interoperate with consumers that expect the standard shape.
Each code has a Problem sender that produces an RFC 7807 “Problem
Details” response with Content-Type: application/problem+json:
errors.InvalidArgument.Problem(ctx,
"Validation Failed", // title
"The email field contains an invalid address", // detail
"https://example.com/problems/validation-failed", // type URI
"/users/create") // instance URI{
"id": "3e9c1b6e-8a3e-4a3c-9d7a-2f2c1a6b5e4d",
"status": 400,
"title": "Validation Failed",
"type": "https://example.com/problems/validation-failed",
"detail": "The email field contains an invalid address",
"instance": "/users/create"
}Trailing extra ...any values become extension members: a single
object-shaped value is merged into the problem at the top level, anything
else (or multiple values) renders under a data key, nils are filtered.
The problem format
is used whenever type, instance and a title are present and no custom
renderer is registered; to render every error as problem+json, set the
package’s renderer:
opts := errors.NewOptions().SetRenderer(errors.Problem)SetRenderer accepts any func(ctx iris.Context, err *errors.Error), so
XML, content negotiation or a bespoke envelope are equally possible.
Logging, Collection and Error IDs
The Options value carries three more hooks, all chainable:
import (
"log/slog"
"uuid"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
opts := errors.NewOptions().
// Called for logged errors (LogErr, Log, private errors,
// unmapped 500s). Default: the application logger.
SetLogger(func(ctx iris.Context, err error) {
slog.Error("http error",
"error", err.Error(),
"path", ctx.Path(),
"method", ctx.Method(),
)
}).
// Called with the complete wire error before it is sent
// (and before private scrubbing), synchronously — offload
// slow work to a goroutine.
SetCollector(func(ctx iris.Context, err *errors.Error) {
slog.LogAttrs(ctx, slog.LevelWarn,
"wire error", err.Attrs()...)
}).
// Error ID generation. Default: a UUID per error.
SetIDGenerator(func(ctx iris.Context) string {
if id := ctx.Header("X-Request-Id"); id != "" {
return id
}
return uuid.New().String()
})SetLogger(func(ctx iris.Context, err error))overrides logging for routes covered by these options; the package-levelerrors.LogErrorvariable is the global fallback.SetCollectorfeeds monitoring: the collector observes the full error (including details that private scrubbing removes from the client copy).(*errors.Error).Attrs()returns ready-madeslogattributes if you log wire errors structurally.SetIDGenerator/SetIDcontrol theidfield. Static IDs viaSetIDare for tests; production should keep unique IDs so a client screenshot can be matched to a log line.
Reporting without rendering
Every method so far ends in a render, which is fine while the response is still unwritten. A streamed response is not: after its first byte the status code is spent, and writing an error body into the middle of an event stream corrupts it. Skipping the error system instead is worse, because the logger and the collector are the only things that carry a failure anywhere, and a stream that fails silently is a stream nobody knows failed.
errors.Report(ctx, err) runs the whole pipeline and returns the *Error
without writing it:
reported := errors.Report(ctx, err) // nil when there is nothing to report.It resolves the error through the route’s error-code map, honours
SkipCanceled, calls the logger with the real error, generates the id,
redacts a private or internal error down to a generic message, and calls
the collector. What comes back is the client-safe copy, and it is the
caller’s to put wherever the transport allows.
errors.ErrorCodeName.Report(ctx, err) is the same thing under a code you
already know, for a failure that has been classified elsewhere.
This is what the sse package writes its error frame with, so a streaming
handler gets the same treatment as every other one without asking for it.
Chapter 6 covers the frame, and
Chapter 21 covers what a relayed upstream failure looks
like once it has been through here.
Router-Level Defaults
Builder-built applications get the error system’s defaults wired without any code:
- Bare failure statuses: a status of 400 or above that reaches the
client with nothing written answers the JSON envelope, with the status
text as the message. It does not matter whether the handler stopped
the chain:
ctx.StopWithStatus(403),ctx.NotFound(), the 401 of thejwtandbasicauthmiddleware and a file the file server does not have all answer it, under the canonical name and status text of their own status (PERMISSION_DENIEDand “Forbidden” for a 403). The 413 of thebodylimitmiddleware is the one whose message is not the status text:TOO_LARGEwith “request body too large”. - Unknown paths and wrong methods: an unknown path answers
404 NOT_FOUND. A method the path does not serve answers405 METHOD_NOT_ALLOWED, with theAllowheader intact, whenOptions.Router.FireMethodNotAllowedis on; it is off by default, and the answer is then the 404 one. - Typed path parameters: a request that fails a macro type check
(
/users/{id:uuid}receivingabc) renders400 INVALID_ARGUMENTwith message “invalid path parameter”, the parse failure as detail and the offending parameter as data. - Panics: the recovery middleware routes panics through
errors.Internal.LogErr: a logged, generic 500 wire error. - Rate limiting: when the
ratemiddleware trips, the client gets429 RESOURCE_EXHAUSTEDwith “you may try again in 2s” as detail. - Response write failures: context-level errors are forwarded to
HandleError.
The first two come from errors.Handler, which the builder registers as
the last of its built-in router middleware, after compression, and as
the 404 and the 405 handler. Your own app.HandleNoRoute or
app.HandleNoMethod, registered from a Configurator given to
NewBuilder or on the value iris.Default() returns, still wins: the
builder fills those two slots only when they are empty at Build.
What the client reads for an error stored on the context follows the
rules of the rest of this chapter, with one rule ahead of them: a
private error is never rendered. Private means stored through
iris.PrivateError or ctx.SetErrPrivate, or carrying the mark
anywhere in its chain, which is what errors.Private builds and what a
private error wrapped again with %w still is. Such an error is
answered before any mapping is asked, with the status the handler set:
below 500 the status text is the message, and at 500 and above the
client reads the generic “server error” while the error goes to the
logger. Any other stored error that your map or a resolver recognises
renders through its mapping; a malformed JSON body stored next to a 400,
for example, answers 400 INVALID_ARGUMENT with “unable to parse
request body”. Otherwise, below 500 the error’s text is the message. At
500 and above a stored *errors.Error is sent as it is, because that
type exists to be sent to clients, and anything else goes to the logger
while the client reads “server error”. A database that is down never has
its address read by a client because somebody forgot to mark the error.
ctx.StopWithPlainError and ctx.SetErr store an error and write
nothing, so the rules above decide the body. ctx.StopWithError with an
error that is not private writes the error’s text itself, so that
response has a body and errors.Handler leaves it alone. With a private
error, whether iris.PrivateError(err), errors.Private(err) or a
rewrap of either, it sets the status, writes nothing and stores the
error as private, so the private rule answers it.
A response that already has a body is left alone, and so is one that
declares a Content-Length, one whose status line is already on the
wire, or one whose connection was hijacked. The declared length matters
for standard handlers mounted with iris.FromStd: one that sets
Content-Length: 0 and calls w.WriteHeader(401), which is what
httputil.ReverseProxy relays for an upstream’s empty 401, keeps its
empty answer, while one that sets a failure status and neither writes,
flushes nor declares a length gets the envelope.
Two failures are answered ahead of errors.Handler and keep their bare
status: a rejection from a handler that a Configurator registered with
UseRouter, which runs before the builder’s own middleware, and the 415
the compression middleware sends for a request body that is not what its
Content-Encoding says.
Manual applications can adopt the same defaults. Where you register
errors.Handler decides what it covers, and UseRouter alone is not
enough for 404 and 405: it runs before the router, and by the time the
chain comes back the router’s default handler has already written
Not Found. Register it as the no-route and no-method handler as well:
func init() { errors.ApplyPatches() }
app := iris.New()
app.UseRouter(opts.Handler()) // your central map
app.UseRouter(errors.Handler) // bare failure statuses
app.HandleNoRoute(errors.Handler) // 404 as a wire error
app.HandleNoMethod(errors.Handler) // 405 (FireMethodNotAllowed)app.Use(errors.Handler) on the root application covers the routes and
the router’s 404 and 405 in one line, but then a HandleNoRoute or
HandleNoMethod registered after it never runs, because in those two
chains errors.Handler answers and does not call the next handler. For
a custom non-JSON 404 page on an iris.New() application, skip
errors.Handler there and register your own handlers with
app.HandleNoRoute / app.HandleNoMethod (Chapter
4). On a builder application, register them
from a Configurator passed to iris.NewBuilder, as above.
Summary
- Domain packages return sentinel errors; one
errors.NewOptions()map, wired through the builder’sErrorsstep (orapp.UseRouter(opts.Handler())), translates them; handlers forward withctx.OK/errors.HandleError. - Every error renders as one wire format:
id,http_error_code(canonical name + status),message, optionaldetail,validationanddata. - Codes are senders:
Message,Detail,Data,Err,LogErr,ErrPrivate,Validation,Problem, andWrapfor deferred handling. On 500 paths preferLogErrorErrPrivate; never leak internals. MapErrorsmatches sentinels,MapErrorTypesmatches types,MapErrorFunc/MapErrorFuncCtxtransform; the default map already coverssql.ErrNoRows, JWT errors and more.pgerrorsadds PostgreSQL via a blank import;RegisterErrorResolveris the extension point.- Validation builders (
validation.String/Number/Slice/Field+validation.Join) produce the standardvalidation failureresponse;ctx.BindJSON[T]()runs yourValidatemethod automatically. errors.Routerlets handlers and middleware return errors instead of writing responses;errors.HTMLanderrors.Problemprovide HTML pages and RFC 7807 output;SetLogger,SetCollectorandSetIDGeneratorhook logging, monitoring and correlation IDs.
Further Reading
- The Go Blog: Error handling and Go:
the foundational piece on errors as values and the
errorinterface. - The Go Blog: Working with Errors in Go 1.13.
Wrapping with
%w,errors.Isanderrors.As: the exact mechanics the central map relies on. - RFC 9110 — HTTP Semantics: Status Codes: the authoritative definition of every 4xx and 5xx status.
- MDN: HTTP response status codes: the same catalog in reference-card form, with usage notes.
- RFC 7807 — Problem Details for HTTP APIs:
the problem+json format behind the
Problemsender (superseded by RFC 9457, which keeps the same wire format). - OWASP Error Handling Cheat Sheet: why clients get generic messages and logs get the details.