30. Advanced error handling: errors.Join, custom types, panic boundaries
30.1 errors.Join: combining independent failures
When several independent operations can each fail and you want to report all of them (not just the first):
var errs []error
for _, task := range tasks {
if err := task.Run(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...) // one error, Unwrap()-able into each part
}
30.2 panic/recover: the boundary, not the flow
Reserve panic for programmer errors (invariant violations you'd rather
crash loudly on than silently corrupt data over) and use recover() only
at a deliberate boundary — typically once, at the top of a goroutine or
an HTTP middleware — never as a substitute for returning an error:
func safeRun(fn func()) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v\n%s", r, debug.Stack())
}
}()
fn()
}
A recover() only works if it's in a defer in the same goroutine
that panicked — a panic in a goroutine you didn't wrap will crash the
whole process, by design. This is why Chapter 24's withRecover
middleware exists per-request: without it, one panicking handler takes
down the entire server.
30.3 Custom error types with structured fields
type RateLimitError struct {
Limit int
RetryAfter time.Duration
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limit of %d exceeded, retry after %s", e.Limit, e.RetryAfter)
}
var err error = &RateLimitError{Limit: 100, RetryAfter: 30 * time.Second}
var rle *RateLimitError
if errors.As(err, &rle) {
w.Header().Set("Retry-After", fmt.Sprint(int(rle.RetryAfter.Seconds())))
}