Session 7 · Errors, panics and retry#

The big idea#

Go has no exceptions for ordinary problems. A function that can fail simply returns an error as its last result, and the caller decides what to do. It feels repetitive at first, but it makes every failure visible right where it can happen.

Everyday example: a courier. When the parcel cannot be delivered, they do not shout down the street; they hand you a slip that says why (“no one home”) and you decide: try again, call, or give up.

In plain words

value, err := doThing(), then if err != nil { ... }. Add context as the error travels up with fmt.Errorf("what I was doing: %w", err). Test for a specific cause with errors.Is, or pull out a custom type with errors.As.

Reach for it when:

  • anything touches files, the network, user input or a database

  • you want a clear message that says what failed and why

  • a flaky call deserves a limited retry

Errors wrap like layers of an onion Each layer adds context with %w. errors.Is and errors.As look through the layers to find the original cause. strconv.Atoi: parsing "abc": invalid syntax main: failed to read order: … parsePositive "abc": … errors.Is(err, target) → true if any layer matches if err != nil { return fmt.Errorf("what I was doing: %w", err) }
Each layer adds context with %w. errors.Is and errors.As look through the layers to find the original cause.

Returning and wrapping errors#

basics.go

package main

import (
	"errors"
	"fmt"
	"strconv"
)

var ErrNegative = errors.New("negative number")

func parsePositive(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parsePositive %q: %w", s, err) // wrap with context
	}
	if n < 0 {
		return 0, fmt.Errorf("parsePositive %d: %w", n, ErrNegative)
	}
	return n, nil
}

func main() {
	for _, in := range []string{"42", "abc", "-5"} {
		n, err := parsePositive(in)
		if err != nil {
			fmt.Println("error:", err)
			fmt.Println("  is negative?", errors.Is(err, ErrNegative))
			continue
		}
		fmt.Println("ok:", n)
	}
}
ok: 42
error: parsePositive "abc": strconv.Atoi: parsing "abc": invalid syntax
  is negative? false
error: parsePositive -5: negative number
  is negative? true
  • errors.New makes a simple error. A package-level var ErrNegative = errors.New(...) is a sentinel callers can compare against.

  • %w in fmt.Errorf wraps the original so it is still reachable. %v would flatten it into text.

  • errors.Is(err, ErrNegative) looks through all the layers.

Custom error types#

custom.go

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field string
	Rule  string
}

func (e *ValidationError) Error() string {
	return e.Field + " " + e.Rule
}

func validate(age int) error {
	if age < 18 {
		return &ValidationError{Field: "age", Rule: "must be 18 or more"}
	}
	return nil
}

func main() {
	err := validate(15)
	fmt.Println(err)

	wrapped := fmt.Errorf("signup failed: %w", err)
	var ve *ValidationError
	if errors.As(wrapped, &ve) { // find a ValidationError anywhere in the chain
		fmt.Println("field:", ve.Field)
	}
	fmt.Println(validate(30) == nil)
}
age must be 18 or more
field: age
true

Any type with an Error() string method is an error. Use errors.As when the caller needs the extra fields (here, which field was wrong).

Retry with back-off#

The syllabus ends with “retry logic and robustness”. Here is the standard shape: try, wait, try again with a longer wait, and finally give up with all the errors attached.

retry.go

package main

import (
	"errors"
	"fmt"
	"time"
)

var calls int

func flaky() error {
	calls++
	if calls < 3 {
		return fmt.Errorf("attempt %d: server busy", calls)
	}
	return nil
}

func retry(attempts int, delay time.Duration, fn func() error) error {
	var errs []error
	for i := 1; i <= attempts; i++ {
		err := fn()
		if err == nil {
			fmt.Println("succeeded on attempt", i)
			return nil
		}
		fmt.Println("failed:", err)
		errs = append(errs, err)
		time.Sleep(delay)
		delay *= 2 // wait longer each time
	}
	return fmt.Errorf("gave up after %d attempts: %w", attempts, errors.Join(errs...))
}

func main() {
	fmt.Println(retry(5, 10*time.Millisecond, flaky))

	err := retry(2, time.Millisecond, func() error { return errors.New("server down") })
	fmt.Println(err)
}
failed: attempt 1: server busy
failed: attempt 2: server busy
succeeded on attempt 3
<nil>
failed: server down
failed: server down
gave up after 2 attempts: server down
server down

errors.Join (Go 1.20+) bundles several errors into one. Only retry errors that are temporary (network timeout, 503). Retrying “invalid password” just repeats the mistake.

Panic and recover#

A panic is for bugs that should never happen (index out of range, nil pointer). It unwinds the stack running deferred functions, and recover() inside a deferred function can catch it.

panic.go

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil // integer divide by zero panics
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(1, 0))
}
5 <nil>
0 recovered: runtime error: integer divide by zero

Important

Use error for things that can reasonably go wrong, panic only for programmer mistakes. A web server often uses recover once at the top so one bad request does not kill the whole process.

Common mistakes#

  • Ignoring errors with _. data, _ := os.ReadFile(f) hides the reason it failed.

  • Returning a non-nil error and a good value, or the reverse, inconsistently. Convention: if err != nil, ignore the other results.

  • Wrapping with %v instead of %w. errors.Is then cannot find the cause.

  • Comparing errors with == on wrapped errors. Use errors.Is.

  • Retrying forever. Always cap attempts and add a delay.

  • Using panic for normal control flow.

Hands-on exercises#

Exercise 1 · Safe square root. Write sqrt(x float64) (float64, error) that returns an error for negative input, using math.Sqrt.

Solution
package main

import (
	"fmt"
	"math"
)

func sqrt(x float64) (float64, error) {
	if x < 0 {
		return 0, fmt.Errorf("sqrt of negative number %v", x)
	}
	return math.Sqrt(x), nil
}

func main() {
	fmt.Println(sqrt(16))
	fmt.Println(sqrt(-4))
}
4 <nil>
0 sqrt of negative number -4

Exercise 2 · Sentinel error. Make withdraw(balance, amount float64) (float64, error) return a package-level ErrInsufficient and check it with errors.Is, even after wrapping.

Solution
package main

import (
	"errors"
	"fmt"
)

var ErrInsufficient = errors.New("insufficient funds")

func withdraw(balance, amount float64) (float64, error) {
	if amount > balance {
		return balance, fmt.Errorf("withdraw %.0f: %w", amount, ErrInsufficient)
	}
	return balance - amount, nil
}

func main() {
	_, err := withdraw(100, 250)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrInsufficient))
}
withdraw 250: insufficient funds
true

Exercise 3 · Retry only the temporary. Extend the retry idea: stop at once when the error is the permanent ErrBadInput, but retry others.

Solution
package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func retry(attempts int, fn func(int) error) error {
	var err error
	for i := 1; i <= attempts; i++ {
		if err = fn(i); err == nil {
			return nil
		}
		fmt.Println("attempt", i, "failed:", err)
		if errors.Is(err, ErrBadInput) {
			return err // permanent: retrying will not help
		}
	}
	return fmt.Errorf("gave up: %w", err)
}

func main() {
	fmt.Println(retry(3, func(n int) error { return errors.New("timeout") }))
	fmt.Println(retry(3, func(n int) error { return ErrBadInput }))
}
attempt 1 failed: timeout
attempt 2 failed: timeout
attempt 3 failed: timeout
gave up: timeout
attempt 1 failed: bad input
bad input