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
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.Newmakes a simple error. A package-levelvar ErrNegative = errors.New(...)is a sentinel callers can compare against.%winfmt.Errorfwraps the original so it is still reachable.%vwould 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
%vinstead of%w.errors.Isthen cannot find the cause.Comparing errors with
==on wrapped errors. Useerrors.Is.Retrying forever. Always cap attempts and add a delay.
Using
panicfor 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