Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run ScanHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
FREEDOM251

Freedom251 › Blog › Error Handling

Go Error Handling That Scales: Sentinels, Wrapping, and One HTTP Middleware Boundary

By Freedom251 Team · · 9 min read

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Build the error path in four deliberate layers: return errors explicitly, expose stable conditions with sentinels or typed errors, wrap only when the underlying cause is part of your API, and classify once at the HTTP boundary. Go’s standard http.Handler does not return an error, so a small application-owned adapter is the clean way to centralize status codes, public messages, and logging.

The error flow to aim for

A maintainable Go service keeps each decision at the layer that has enough information to make it. Low-level functions report failure as an ordinary return value. Domain or package code decides which conditions callers may rely on. Higher layers add context without accidentally exposing implementation details. The HTTP boundary translates the final condition into one response.

As an Amazon Associate I earn from qualifying purchases.

  1. Return an error value. Do not hide failure in a global variable, panic, or a string embedded in a successful result.
  2. Choose a public identity. Use a reused sentinel for a stable category, or a custom type when callers need structured fields.
  3. Wrap intentionally. Add operation context with %w only when callers should be able to inspect the wrapped cause.
  4. Classify once. At the HTTP adapter, use errors.Is or errors.As, select an application-owned status and message, and write the response.

This separation prevents a handler from turning every internal failure into an ad hoc status code and prevents clients from depending on unstable database, filesystem, or vendor-specific text.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Errors are explicit return values

Go represents a failed operation with an additional return value, conventionally the last one. A successful call returns nil for that value; a failed call returns a non-nil error. The caller then chooses whether to add context, retry, translate the condition, or return it unchanged.

func loadUser(ctx context.Context, id string) (User, error) {
    row, err := queryUser(ctx, id)
    if err != nil {
        return User{}, err
    }
    return decodeUser(row)
}

func service(ctx context.Context, id string) (User, error) {
    user, err := loadUser(ctx, id)
    if err != nil {
        return User{}, fmt.Errorf(`load user %q: %w`, id, err)
    }
    return user, nil
}

The caller checks err before using the result. Returning the error preserves control flow: a transport layer can map a known condition to a client response, while an unexpected condition can be logged and treated as an internal failure.

Sentinels for stable, recognizable conditions

A sentinel is a package-level error value that names a condition other code may need to recognize. Reuse the same value; each call to errors.New creates a different instance, even when the text is identical.

package users

import `errors`

var ErrNotFound = errors.New(`user not found`)

func lookup(id string) (User, error) {
    user, ok := inMemoryLookup(id)
    if !ok {
        return User{}, fmt.Errorf(`lookup %q: %w`, id, ErrNotFound)
    }
    return user, nil
}

Consumers should not compare errors with == after a function has added context. Use errors.Is, which searches the unwrap chain and still finds the sentinel.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
user, err := lookup(id)
if err != nil {
    if errors.Is(err, users.ErrNotFound) {
        // The stable condition is known at this layer.
        return User{}, ErrMissing
    }
    return User{}, err
}

Export a sentinel only when callers genuinely need that category. Once exported and documented, its meaning becomes part of your package contract. Keep internal implementation failures private when no caller should branch on them.

Typed errors when callers need data

A sentinel answers “which category is this?” A custom error type answers that question and carries fields such as a field name, an operation, or a retry interval. Define the type around information that is safe and useful to callers.

type ValidationError struct {
    Field  string
    Reason string
}

func (e *ValidationError) Error() string {
    return e.Field + `: ` + e.Reason
}

func validate(input Signup) error {
    if input.Email == `` {
        return &ValidationError{Field: `email`, Reason: `is required`}
    }
    return nil
}

Use errors.As to locate that type through any number of wrappers and retrieve its fields.

var validation *ValidationError
if errors.As(err, &validation) {
    log.Printf(`invalid field %s`, validation.Field)
}

The target passed to errors.As must be a pointer to a variable whose type can hold the matching error. For a value type, use the corresponding value variable; for a pointer error type, use a pointer variable as shown. Newer Go toolchains also document errors.AsType as a typed alternative. Check the minimum Go version your module supports before adopting version-sensitive helpers.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What %w promises

fmt.Errorf with %w creates an unwrap relationship. It adds readable context while allowing errors.Is and errors.As to inspect the original cause.

if err != nil {
    return fmt.Errorf(`read profile from cache: %w`, err)
}

Wrapping is not merely formatting; it is an API decision. If your package wraps a dependency’s sentinel or concrete type, callers can begin branching on that dependency. The wrapped identity is then part of your observable behavior and can constrain a future implementation change.

Expose the cause when it is intentional

Use %w when callers need to distinguish a condition, retry based on a cause, or extract documented fields. Document those conditions so users know which identities are stable.

Hide details when they are implementation-specific

If a database driver, file layout, or remote client is an internal choice, exposing its error type makes that choice a compatibility burden. Add text without an unwrap relationship, or translate the failure to a package-owned sentinel or type.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
// The storage driver's error is deliberately hidden.
return fmt.Errorf(`load settings failed: %v`, err)

Using %v here means callers cannot discover the driver error with errors.Is or errors.As. That is correct only when hiding the dependency is intentional. Conversely, adding %w to every error “just in case” can expose more of your implementation than you want to support.

Why standard HTTP handlers need an adapter

The standard http.Handler interface has the shape ServeHTTP(ResponseWriter, *Request); it has no error return. http.HandlerFunc follows the same contract. A generic middleware cannot receive an error from a handler that has already returned only by completing ServeHTTP.

You can still centralize error handling by defining an application function type that does return an error, then adapting it once to http.Handler.

type AppHandler func(http.ResponseWriter, *http.Request) error

func (h AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := h(w, r); err != nil {
        writeError(w, r, err)
    }
}

Every endpoint uses the application type. The adapter becomes the one place that classifies known conditions, logs unexpected failures, and writes a deliberate response.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
func getUser(w http.ResponseWriter, r *http.Request) error {
    id := r.PathValue(`id`)
    user, err := service(r.Context(), id)
    if err != nil {
        return fmt.Errorf(`get user %q: %w`, id, err)
    }
    return writeJSON(w, http.StatusOK, user)
}

mux := http.NewServeMux()
mux.Handle(`/users/`, AppHandler(getUser))
server := &http.Server{Handler: mux}

The exact route registration depends on how your router extracts parameters; the important property is that the endpoint returns an error instead of choosing its own status response.

Classify errors once at the boundary

There is no universal Go rule that maps every error to an HTTP status. Define categories that make sense for your application and map only those categories you have chosen to expose.

func writeError(w http.ResponseWriter, r *http.Request, err error) {
    switch {
    case errors.Is(err, ErrNotFound):
        writeProblem(w, http.StatusNotFound, `resource not found`)

    case errors.Is(err, ErrConflict):
        writeProblem(w, http.StatusConflict, `resource conflict`)

    default:
        slog.ErrorContext(r.Context(), `request failed`, `error`, err)
        writeProblem(w, http.StatusInternalServerError, `internal server error`)
    }
}

The public message is stable and intentionally narrower than the internal error. Do not send a wrapped database message, stack trace, file path, or dependency name to a client unless that detail is explicitly part of your API. Log the original error with request context where your operational policy calls for it.

Typed errors can provide safe response fields, but extract only fields that are documented as client-facing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
func writeError(w http.ResponseWriter, r *http.Request, err error) {
    var validation *ValidationError
    switch {
    case errors.As(err, &validation):
        writeJSON(w, http.StatusBadRequest, map[string]string{
            `field`:   validation.Field,
            `message`: validation.Reason,
        })
    case errors.Is(err, ErrNotFound):
        writeProblem(w, http.StatusNotFound, `resource not found`)
    default:
        slog.ErrorContext(r.Context(), `request failed`, `error`, err)
        writeProblem(w, http.StatusInternalServerError, `internal server error`)
    }
}

Order matters when categories overlap. Put the most specific, intentionally exposed type checks before a broad fallback. Keep the mapping function small enough that reviewers can see every public outcome.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Sentinel, type, wrapping, and boundary decisions

Need Go mechanism HTTP-boundary use
A stable category callers can recognize Reused sentinel and errors.Is Map to an intentional status and public message
Structured information Custom error type with errors.As or a supported errors.AsType Expose only documented, safe fields
Context while retaining inspectability fmt.Errorf with %w Preserve classification through the adapter
Hide an implementation-specific cause Context with %v, or translate to a package-owned error Use a stable public category or fallback response

Response commitment is a hard boundary

An adapter cannot reliably replace a response after the handler has committed headers or written body bytes. ResponseWriter does not provide a general rollback transaction.

Adopt one of these policies explicitly:

  • Return before writing. Perform validation and fallible work first, then write the response only after the operation succeeds.
  • Buffer output. Build the complete representation in memory or another buffer, then copy it to the writer once no error remains.
  • Document late-failure behavior. For streaming endpoints, define what a mid-stream failure means; the adapter may only be able to log it after bytes have left the process.

Do not promise that middleware can undo a partial response. If an endpoint writes a success header and later returns an error, the adapter may be limited to logging.

A practical implementation sequence

  1. List caller-visible conditions. Identify cases such as not found, invalid input, conflict, or permission failure that another package or the HTTP API must distinguish.
  2. Choose the representation. Use a sentinel for a category with no data; use a typed error when fields are required; keep purely internal failures unexported.
  3. Wrap at operation boundaries. Add identifiers and action names with %w only when preserving the cause is part of the contract.
  4. Translate at abstraction boundaries. Convert dependency errors into your own category when callers should not depend on the dependency.
  5. Adapt once. Register AppHandler values so endpoint code returns errors consistently.
  6. Map in one function. Use errors.Is for sentinels, errors.As for types, and a deliberate fallback for everything else.
  7. Test the chain. Verify classification through multiple wrappers and verify the exact status and public body produced by the adapter.

Testing the contract

Tests should assert behavior rather than error strings. A unit test for a wrapped sentinel proves that context does not break classification.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
func TestLookupNotFound(t *testing.T) {
    _, err := lookup(`missing`)
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf(`errors.Is() = false, want true: %v`, err)
    }
}

For a typed error, assert both recognition and the fields that callers are promised.

func TestValidation(t *testing.T) {
    err := validate(Signup{})
    var ve *ValidationError
    if !errors.As(err, &ve) {
        t.Fatal(`errors.As() = false`)
    }
    if ve.Field != `email` {
        t.Fatalf(`field = %q, want email`, ve.Field)
    }
}

Use httptest.NewRecorder and a request to test the adapter’s status and body. Include an unexpected error case to ensure internal text is not leaked, and include a wrapped known error to prove the middleware sees through context.

Common mistakes and their fixes

  • Comparing error strings. Text changes when context is added. Use errors.Is or errors.As.
  • Creating a new sentinel on every call. A fresh errors.New value cannot match the package sentinel. Declare and reuse one value.
  • Using %v when callers need the cause. This destroys programmatic inspection. Use %w when exposure is intentional.
  • Wrapping every dependency error. That can make an implementation detail part of your API. Translate or hide it when appropriate.
  • Returning raw internal errors to clients. Keep diagnostics in logs and send a stable public message.
  • Letting each handler choose its own mapping. Centralize classification so status policy remains consistent.
  • Assuming middleware can repair a written response. Enforce a no-write-before-success rule, buffer, or document streaming behavior.

Version and compatibility notes

The wrapping and inspection model discussed in the official Go guidance dates from Go 1.13 and remains the basis for errors.Is, errors.As, and %w. The errors package documentation is updated independently of your application, and newer helpers such as errors.AsType may not exist in the minimum toolchain used by an older module. Check go.mod, your CI toolchain, and the target deployment before using newer syntax.

The single error-returning middleware shown here is an application convention built around the standard handler signature, not a feature provided by net/http. You own its response schema, logging policy, status mapping, and rules for late failures.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Error Handlingerrors.Aserrors.IsGoGolangHTTP middlewarenet/httpsentinels

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Recommended PC Tool
Recommended PC Tool
Crashes, No Sound, or Screen Glitches?Free driver scan
PC Slower Than It Used to Be?Free scan - under a minute

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.