Back to Oh My Openagent

Error Handling

packages/shared-skills/skills/programming/references/go/error-handling.md

4.5.1210.1 KB
Original Source

Error Handling

Typed errors, wrap chains, errors.Is / errors.As, no panic in libraries, resource cleanup. Go errors look simple and are full of footguns. This document is the canonical set of moves.


The five rules

  1. Every error is wrapped on the way up, with %w, with context. Never return err from a non-trivial site.
  2. Compare with errors.Is, not ==. Wrap chains break ==. The errorlint linter forbids == on errors.
  3. Cast with errors.As, not type assertion. Same reason.
  4. panic is reserved for programmer errors. Library code never panics on user input or environment failures. Use (T, error).
  5. Resources released via defer immediately after acquisition. No "I'll add it later".

Sentinel errors — for invariant programmatic checks

go
package domain

import "errors"

var (
    ErrInvalidEmail   = errors.New("domain: invalid email")
    ErrInvalidPhone   = errors.New("domain: invalid phone")
    ErrInvalidAge     = errors.New("domain: invalid age")
)

func NewEmail(s string) (Email, error) {
    if !emailRe.MatchString(s) {
        return Email{}, fmt.Errorf("email %q: %w", s, ErrInvalidEmail)
    }
    return Email{raw: strings.ToLower(s)}, nil
}

Caller branches on identity:

go
email, err := domain.NewEmail(input)
if errors.Is(err, domain.ErrInvalidEmail) {
    return c.JSON(400, gin.H{"error": "email format"})
}

errors.Is walks the wrap chain. err == domain.ErrInvalidEmail would have failed because fmt.Errorf wrapped it.


Typed errors — when you need structured data

When callers need fields off the error (the offending value, the failing field name, the upstream HTTP status):

go
type ValidationError struct {
    Field   string
    Value   string
    Rule    string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s=%q failed %s", e.Field, e.Value, e.Rule)
}

// Optional: identity sentinel for errors.Is comparisons
var ErrValidation = errors.New("validation")

func (e *ValidationError) Is(target error) bool {
    return target == ErrValidation
}

Caller:

go
err := svc.Save(ctx, user)

var vErr *ValidationError
if errors.As(err, &vErr) {
    // vErr.Field, vErr.Rule are available
    c.JSON(400, gin.H{"field": vErr.Field, "rule": vErr.Rule})
    return
}

errors.As requires a non-nil pointer-to-pointer. Almost always the type is *ConcreteError. Forgetting the leading * is the most common bug here.


Wrapping — %w is mandatory

go
// BAD — drops context
return err

// BAD — drops the error chain (errors.Is/As stops working)
return fmt.Errorf("failed to save user: %v", err)

// GOOD — preserves chain via %w
return fmt.Errorf("save user %s: %w", userID, err)

The errorlint linter catches %v where %w was meant. Wrap once per layer, with the minimum useful context:

api/handler:  "create user request: %w"
   service:   "validate inputs: %w"
      domain: "email %q: %w"

Each frame adds one fact, not a duplicate. The top-level error message reads as a path: create user request: validate inputs: email "foo": domain: invalid email.

errors.Join — multiple errors at once

go
// Validate all fields, collect all errors
var errs []error
if _, err := NewEmail(req.Email); err != nil {
    errs = append(errs, fmt.Errorf("email: %w", err))
}
if _, err := NewUsername(req.Username); err != nil {
    errs = append(errs, fmt.Errorf("username: %w", err))
}
if len(errs) > 0 {
    return errors.Join(errs...)
}

errors.Is still walks each joined error. Use when reporting batch validation, not for "wrap two unrelated errors".


Panics — when allowed, when banned

Banned:

  • Anywhere a (T, error) could be returned.
  • Inside HTTP handlers (gin's Recovery middleware catches them, but you've already lost the error context).
  • Inside any goroutine that survives request lifetime.

Allowed (with documentation):

  • Map literal init at package level: var statusNames = map[Status]string{...} followed by a func init() that panics if a const has no name. Catches the bug at startup, not runtime.
  • The must* convention for genuinely unrecoverable startup:
    go
    func MustParseURL(s string) *url.URL {
        u, err := url.Parse(s)
        if err != nil { panic(err) }
        return u
    }
    // Use only with literals known at compile time:
    var defaultAPI = MustParseURL("https://api.example.com")
    
  • default: case of an exhaustive sealed-interface switch — see type-patterns.md.

The revive linter rule error-return will flag suspect panic sites; treat them as bugs.


defer for resources — the only safe pattern

go
func writeReport(path string) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("create %s: %w", path, err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("close %s: %w", path, cerr)
        }
    }()

    if _, err := f.Write(data); err != nil {
        return fmt.Errorf("write %s: %w", path, err)
    }
    return nil
}

Key points:

  • defer f.Close() immediately after os.Create — never further down.
  • Named return (err error) so the deferred close can mutate it on close failure.
  • bodyclose linter catches missed defer resp.Body.Close() for HTTP responses.
  • sqlclosecheck linter catches missed defer rows.Close() for SQL.

errors.Join for multi-stage cleanup

go
func process(path string) (err error) {
    f, err := os.Open(path)
    if err != nil { return err }
    defer func() {
        err = errors.Join(err, f.Close())
    }()
    // ... use f ...
    return nil
}

When both the main operation AND Close can fail, errors.Join reports both without dropping either.


HTTP error responses — a single funnel

Build one helper, route all handler errors through it:

go
package httperr

type APIError struct {
    Status  int    `json:"-"`
    Code    string `json:"code"`
    Message string `json:"message"`
}

func (e *APIError) Error() string { return e.Code + ": " + e.Message }

var (
    NotFound       = &APIError{Status: 404, Code: "not_found", Message: "resource not found"}
    Unauthorized   = &APIError{Status: 401, Code: "unauthorized", Message: "unauthorized"}
    BadRequest     = &APIError{Status: 400, Code: "bad_request", Message: "bad request"}
    Internal       = &APIError{Status: 500, Code: "internal", Message: "internal error"}
)

// Wrap a domain error into an API error.
func From(err error) *APIError {
    if err == nil { return nil }

    var apiErr *APIError
    if errors.As(err, &apiErr) { return apiErr }

    switch {
    case errors.Is(err, domain.ErrInvalidEmail),
         errors.Is(err, domain.ErrInvalidUsername):
        return &APIError{Status: 400, Code: "validation", Message: err.Error()}
    case errors.Is(err, ErrNotFound):
        return NotFound
    case errors.Is(err, ErrUnauthorized):
        return Unauthorized
    default:
        // unknown — log full chain, return generic
        slog.Error("unmapped error", slog.Any("err", err))
        return Internal
    }
}

func Write(c *gin.Context, err error) {
    apiErr := From(err)
    c.JSON(apiErr.Status, apiErr)
}

Handlers become trivial:

go
func (h *Handler) Create(c *gin.Context) {
    user, err := h.svc.Create(c.Request.Context(), req)
    if err != nil {
        httperr.Write(c, err)
        return
    }
    c.JSON(201, user)
}

errgroup — error propagation across goroutines

go
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8)  // concurrency cap

    results := make([][]byte, len(urls))
    for i, u := range urls {
        g.Go(func() error {
            body, err := fetch(ctx, u)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", u, err)
            }
            results[i] = body
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}
  • errgroup.WithContext cancels remaining tasks on first error.
  • SetLimit bounds concurrency.
  • First non-nil error is returned; others are discarded — by design.

See concurrency.md for the full pattern.


Logging errors — structured, once

go
slog.ErrorContext(ctx, "save user failed",
    slog.String("user_id", string(id)),
    slog.Any("err", err),   // %w chain is fully rendered
)

Log once, at the outermost frame. Logging at every wrap site produces five log lines for one error.

The sloglint linter enforces slog.Any("err", err) over slog.String("err", err.Error()) — the former preserves the chain when handlers walk the value.


Antipatterns

BadWhyGood
_ = errSilent ignoreHandle, log, or wrap
if err != nil { return err } chained 10 deep without wrapNo path infoAdd one fact per layer: fmt.Errorf("step: %w", err)
panic(err) in HTTP handlersLoses error chain, hits gin Recoveryhttperr.Write(c, err)
err.Error() == "some string"Brittle, breaks on wrapDefine a sentinel, use errors.Is
if err == sql.ErrNoRowsBreaks under wraperrors.Is(err, sql.ErrNoRows)
catch-all log.Fatal(err) in library codeCrashes the caller's processReturn error, let main decide
Returning a typed nil pointer wrapped in error interfaceClassic "nil != nil" bugReturn explicit nil for the error

The last bug deserves its own example:

go
// BUG — returns a non-nil error interface containing a nil concrete type
func bad() error {
    var e *MyError = nil
    return e  // interface wraps nil pointer; errors == nil is FALSE
}

// Caller
if err := bad(); err != nil {
    // ← entered, but err.(*MyError) is nil — surprise panic
}

Fix: return explicit nil, not a typed nil. The nilnil linter catches this in (T, error) returns.


Sources