Frontend Labs
⋯

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())))
}

Generated from real-life-golang-guide.md · 8 Oct 2026 16:36