Chapter 9

Error Handling

The rest/errors package: canonical codes, one wire format, and a single central map from Go error to HTTP response. Internals never leak to clients.

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:

  1. The domain defines sentinel errors. A service or repository returns store.ErrNotFound, store.ErrShelfLocked. These are plain errors.New values that know nothing about HTTP.
  2. The application maps them once. A single errors.NewOptions() value, handed to the builder’s Errors step, associates each sentinel with a canonical code. errors.Is does the matching, so wrapping with fmt.Errorf("...: %w", err) anywhere in the call chain never breaks it.
  3. Handlers just forward. ctx.OK, errors.HandleError and friends send any non-nil error through the same pipeline. A handler never decides status codes for domain failures.
Go
// 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")
)
Go
// 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.)

Go
// 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):

JSON
{
    "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:

ConstantCanonical nameStatus
errors.InvalidArgumentINVALID_ARGUMENT400
errors.FailedPreconditionFAILED_PRECONDITION400
errors.OutOfRangeOUT_OF_RANGE400
errors.UnauthenticatedUNAUTHENTICATED401
errors.PaymentRequiredPAYMENT_REQUIRED402
errors.PermissionDeniedPERMISSION_DENIED403
errors.NotFoundNOT_FOUND404
errors.MethodNotAllowedMETHOD_NOT_ALLOWED405
errors.AlreadyExistsALREADY_EXISTS409
errors.AbortedABORTED409
errors.TooLargeTOO_LARGE413
errors.UnsupportedMediaTypeUNSUPPORTED_MEDIA_TYPE415
errors.UnprocessableEntityUNPROCESSABLE_ENTITY422
errors.ResourceExhaustedRESOURCE_EXHAUSTED429
errors.InternalINTERNAL500
errors.DataLossDATA_LOSS500
errors.UnknownUNKNOWN500
errors.UnimplementedUNIMPLEMENTED501
errors.BadGatewayBAD_GATEWAY502
errors.UnavailableUNAVAILABLE503
errors.DeadlineExceededDEADLINE_EXCEEDED504

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:

Go
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:

MethodSignature (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) errorTags an error, sends nothing
Go
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:

Go
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:

Go
// 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:

Go
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/jwt token errors (jwt.ErrExpired, jwt.ErrMissing, jwt.ErrTokenSignature, jwt.ErrBlocked and the rest of the family) → Unauthenticated
  • view.ErrNoEngine (from middleware/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:

Go
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 via errors.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.As on 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’s Filter(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.

Go
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:

Go
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):

Go
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:

Go
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:

Go
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:

Go
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:

Go
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.

How errors.HandleError classifies an error errors.HandleError walks ten tests in order and the first match writes the response, so a more specific rule always beats a more general one and an unrecognised error ends at a logged 500. errors.HandleError(ctx, err) returns false and writes nothing when err is nil, or when the context is already stopped. serverError = !errors.Is(err, ErrRequest). FIRST MATCH WINS WHAT IT WRITES 1. Options error-code map the mapped code, using the map's transformed error 2. registered error resolvers whatever the resolver sends; the first true wins 3. context.ErrRequestEntityTooLarge 413 TOO_LARGE, request body too large 4. *context.ErrPanicRecovery 500 INTERNAL, logged, and the request is stopped 5. schema.MultiError 400 INVALID_ARGUMENT, invalid request value(s) 6. a JSON decoding error 400 INVALID_ARGUMENT, unable to parse request body 7. an ErrorCodeName in the chain that code, with the tag stripped from the message 8. ValidationError / ValidationErrors 400 INVALID_ARGUMENT with the validation array 9. client.APIError the remote body: 4xx as 400, 5xx as 500 10. nothing matched 500 INTERNAL, logged, generic message HandleRequestError differs at step 6 only: a JSON decode failure renders 400 rather than 500. An error that no step recognises still ends at 500 either way.
errors.HandleError walks ten tests in order and the first match writes the response, so a more specific rule always beats a more general one and an unrecognised error ends at a logged 500.

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 renders 400 INVALID_ARGUMENT (“unable to parse request body”) rather than a 500. An error that no step recognizes still ends at 500 INTERNAL either 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:
Go
// 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:

ConstructorChainable 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):

Go
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:

JSON
{
    "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:

Go
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):

Go
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 inside MapErrorFunc transformations.
  • 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.Error or a validation error gets the generic message here too.
Go
// 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:

JSON
{
    "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:

Go
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_EXISTS with 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_ARGUMENT with 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:

Go
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):

Go
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:

Go
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:

Go
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:

Go
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
JSON
{
    "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:

Go
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:

Go
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-level errors.LogError variable is the global fallback.
  • SetCollector feeds monitoring: the collector observes the full error (including details that private scrubbing removes from the client copy). (*errors.Error).Attrs() returns ready-made slog attributes if you log wire errors structurally.
  • SetIDGenerator / SetID control the id field. Static IDs via SetID are 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:

Go
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 the jwt and basicauth middleware and a file the file server does not have all answer it, under the canonical name and status text of their own status (PERMISSION_DENIED and “Forbidden” for a 403). The 413 of the bodylimit middleware is the one whose message is not the status text: TOO_LARGE with “request body too large”.
  • Unknown paths and wrong methods: an unknown path answers 404 NOT_FOUND. A method the path does not serve answers 405 METHOD_NOT_ALLOWED, with the Allow header intact, when Options.Router.FireMethodNotAllowed is 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} receiving abc) renders 400 INVALID_ARGUMENT with 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 rate middleware trips, the client gets 429 RESOURCE_EXHAUSTED with “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:

Go
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’s Errors step (or app.UseRouter(opts.Handler())), translates them; handlers forward with ctx.OK / errors.HandleError.
  • Every error renders as one wire format: id, http_error_code (canonical name + status), message, optional detail, validation and data.
  • Codes are senders: Message, Detail, Data, Err, LogErr, ErrPrivate, Validation, Problem, and Wrap for deferred handling. On 500 paths prefer LogErr or ErrPrivate; never leak internals.
  • MapErrors matches sentinels, MapErrorTypes matches types, MapErrorFunc/MapErrorFuncCtx transform; the default map already covers sql.ErrNoRows, JWT errors and more. pgerrors adds PostgreSQL via a blank import; RegisterErrorResolver is the extension point.
  • Validation builders (validation.String/Number/Slice/Field + validation.Join) produce the standard validation failure response; ctx.BindJSON[T]() runs your Validate method automatically.
  • errors.Router lets handlers and middleware return errors instead of writing responses; errors.HTML and errors.Problem provide HTML pages and RFC 7807 output; SetLogger, SetCollector and SetIDGenerator hook logging, monitoring and correlation IDs.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.