Go for Infrastructure Engineers›03 · Errors done right

Lesson 03 of 9 · Part 1 — The language

Errors done right

Handle errors the Go way: errors are values you return and check, wrap them with context, inspect them with errors.Is and errors.As, clean up with defer, and keep panic for programmer mistakes.

Beginner → Practitioner
Key wordserrorerrors.Newfmt.Errorf%werrors.Iserrors.Assentinel errorscustom error typespanicrecoverdefer

Errors are values

Functions that can fail return an error as their last result. The caller checks it right away:

package main

import (
    "fmt"
    "os"
    "strings"
)

func readToken(path string) (string, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return "", fmt.Errorf("read token %s: %w", path, err)
    }
    return strings.TrimSpace(string(b)), nil
}

func main() {
    tok, err := readToken("/var/run/secrets/kubernetes.io/serviceaccount/token")
    if err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
    fmt.Println("token length:", len(tok))
}

Compared with Python exceptions, failure paths are visible in the code. Compared with Bash, you can't forget set -e: an unused err is easy to spot, and linters flag ignored errors.

A Go function hands you two things: the parcel and a delivery note. Before opening the parcel, you read the note. If the note says "damaged", you don't open it; you pass the note along with a line of your own: "damaged on the way to the warehouse".

Wrap with context

Each layer adds what it was doing, so the final message reads like a path:

error: sync cluster prod-eu: list nodes: Get "https://10.0.0.1:6443/api/v1/nodes": dial tcp 10.0.0.1:6443: i/o timeout

Use %w to wrap (keeps the chain), and lower-case messages without trailing punctuation so they join cleanly.

Inspect: errors.Is and errors.As

package main

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)

var ErrNoNodes = errors.New("no nodes found")

type QuotaError struct {
    Resource string
    Limit    int
}

func (e *QuotaError) Error() string { return fmt.Sprintf("quota exceeded for %s (limit %d)", e.Resource, e.Limit) }

func scale(n int) error {
    if n == 0 {
        return fmt.Errorf("scale pool: %w", ErrNoNodes)
    }
    if n > 10 {
        return fmt.Errorf("scale pool: %w", &QuotaError{Resource: "cpus", Limit: 40})
    }
    return nil
}

func main() {
    for _, n := range []int{0, 20, 3} {
        err := scale(n)
        var qe *QuotaError
        switch {
        case err == nil:
            fmt.Println(n, "ok")
        case errors.Is(err, ErrNoNodes):
            fmt.Println(n, "nothing to do")
        case errors.As(err, &qe):
            fmt.Println(n, "ask for more", qe.Resource)
        }
    }
    if _, err := os.Stat("/nope"); errors.Is(err, fs.ErrNotExist) {
        fmt.Println("file missing")
    }
}
  • errors.Is matches a specific value anywhere in the chain (sentinels such as fs.ErrNotExist, context.DeadlineExceeded).
  • errors.As finds an error of a type and gives you its fields.
  • Kubernetes code uses helpers in the same spirit: apierrors.IsNotFound(err), apierrors.IsConflict(err) (lesson 07).

defer for clean-up

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close() // runs when the function returns, on every path

Deferred calls run in reverse order. Common uses: closing files and response bodies, unlocking mutexes, cancelling contexts.

panic is for bugs

Return errors for anything that can happen in normal operation: missing files, timeouts, API errors, bad user input. panic is for states that should be impossible (an index you've already checked, a nil that can't be nil). Long-running services and controllers recover from panics at the top of each request or reconcile so one bad object doesn't kill the process; controller-runtime does this for you.

Try it: an error you can act on

  1. Write loadConfig(path string) (Config, error) that reads and parses a YAML or JSON file, wrapping each failure with context.
  2. Call it with a missing file, a file with bad syntax and a good file; print the full error each time.
  3. Add a sentinel ErrMissingCluster returned when a required field is empty, and handle it specially with errors.Is.
  4. Add a custom error type with a field and handle it with errors.As.
  5. Install errcheck (or golangci-lint) and find an ignored error you added on purpose.

Going deeper: errors at boundaries

  • Log an error once, where you handle it (usually at the top), not at every layer that returns it.
  • Use errors.Join to return several errors at once (validation of many fields).
  • At process boundaries, map errors to exit codes (CLI) or HTTP status codes (service) deliberately.

Recap

  • Errors are returned values; check them immediately.
  • Wrap with fmt.Errorf("doing x: %w", err) so messages read as a path.
  • Inspect with errors.Is (values) and errors.As (types).
  • defer for clean-up; panic only for bugs.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.