Skip to content

panic and recover — Tasks

Hands-on exercises. Each comes with a problem statement, hints, and a reference solution. Difficulty: easy → hard.


Task 1 (Easy) — Trigger and recover from a basic panic

Write a function safeRun(fn func()) (recovered any) that runs fn and returns whatever was passed to panic, or nil if no panic occurred.

Hints - Use defer and recover. - Need a named return.

Solution

package main

import "fmt"

func safeRun(fn func()) (recovered any) {
    defer func() {
        recovered = recover()
    }()
    fn()
    return nil
}

func main() {
    fmt.Println(safeRun(func() { /* nothing */ })) // <nil>
    fmt.Println(safeRun(func() { panic("boom") })) // boom
}


Task 2 (Easy) — Convert panic to error

Write a function Run(fn func()) (err error) that runs fn and returns an error if it panics. The error message should include the recovered value.

Hints - (err error) is named so the deferred recover can write to it. - Use fmt.Errorf("recovered: %v", r).

Solution

func Run(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    fn()
    return nil
}

func main() {
    err := Run(func() { panic("oops") })
    fmt.Println(err) // recovered: oops
}


Task 3 (Easy) — MustParse helper

Implement MustAtoi(s string) int that panics if the input is not a valid integer.

Hints - Use strconv.Atoi. - Panic with a string that includes s.

Solution

func MustAtoi(s string) int {
    n, err := strconv.Atoi(s)
    if err != nil {
        panic(fmt.Sprintf("MustAtoi(%q): %v", s, err))
    }
    return n
}

func main() {
    fmt.Println(MustAtoi("42")) // 42
    // MustAtoi("abc") would panic
}


Task 4 (Easy → Medium) — Inspect the panic value

Write Classify(fn func()) string that returns: - "no panic" if fn does not panic. - "error panic" if it panics with an error. - "string panic" if it panics with a string. - "other panic" otherwise.

Solution

func Classify(fn func()) (kind string) {
    kind = "no panic"
    defer func() {
        r := recover()
        if r == nil {
            return
        }
        switch r.(type) {
        case error:
            kind = "error panic"
        case string:
            kind = "string panic"
        default:
            kind = "other panic"
        }
    }()
    fn()
    return
}


Task 5 (Medium) — Goroutine-safe wrapper

Implement goSafe(fn func()) that launches fn in a goroutine and recovers any panic, logging it via log.Printf. Demonstrate that the main goroutine continues to run.

Hints - Use defer inside the goroutine.

Solution

func goSafe(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("goroutine panic: %v", r)
            }
        }()
        fn()
    }()
}

func main() {
    goSafe(func() { panic("worker panic") })
    time.Sleep(100 * time.Millisecond)
    fmt.Println("main still alive") // prints
}


Task 6 (Medium) — HTTP middleware that recovers

Write Recover(next http.Handler) http.Handler that catches panics in next.ServeHTTP and returns 500 to the client. Log the recovered value and stack trace.

Hints - runtime/debug.Stack() for the trace. - http.Error(w, "internal error", 500).

Solution

import (
    "net/http"
    "log"
    "runtime/debug"
)

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic in handler: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}


Task 7 (Medium) — Stack trace capture

Write RecoverWithStack(fn func()) (msg string, stack string) that runs fn, returning the recovered message and the captured stack trace. Empty strings if no panic.

Solution

import "runtime/debug"

func RecoverWithStack(fn func()) (msg string, stack string) {
    defer func() {
        if r := recover(); r != nil {
            msg = fmt.Sprintf("%v", r)
            stack = string(debug.Stack())
        }
    }()
    fn()
    return "", ""
}


Task 8 (Medium) — Re-panic on unknown values

Write a recover that handles a custom panic type MyPanic and re-panics any other value. Demonstrate that user code triggers MyPanic and gets handled, while a runtime panic (nil deref) propagates further.

Solution

type MyPanic struct{ Msg string }

func handleMine(fn func()) (handled bool) {
    defer func() {
        if r := recover(); r != nil {
            if mp, ok := r.(MyPanic); ok {
                log.Printf("handled mine: %s", mp.Msg)
                handled = true
                return
            }
            panic(r) // re-panic unknown
        }
    }()
    fn()
    return false
}

If fn calls panic(MyPanic{"x"}), handleMine catches it. If fn does var p *int; *p = 1, the runtime panic re-panics out.


Task 9 (Medium → Hard) — Multi-error from panicking workers

Spawn N goroutines each calling a function that may panic. Collect all panic values into a slice and return them as a single combined error using errors.Join.

Hints - Each goroutine recovers its own panic. - A mutex-protected slice or a channel collects results.

Solution

import (
    "errors"
    "fmt"
    "sync"
)

func RunAll(workers []func()) error {
    var (
        mu   sync.Mutex
        errs []error
        wg   sync.WaitGroup
    )
    for i, w := range workers {
        i, w := i, w
        wg.Add(1)
        go func() {
            defer wg.Done()
            defer func() {
                if r := recover(); r != nil {
                    mu.Lock()
                    errs = append(errs, fmt.Errorf("worker %d: %v", i, r))
                    mu.Unlock()
                }
            }()
            w()
        }()
    }
    wg.Wait()
    return errors.Join(errs...)
}


Task 10 (Hard) — Detect runtime.Error vs user panic

Write Categorize(fn func()) string that returns: - "runtime" if the panic value implements runtime.Error. - "user" for any other non-nil panic. - "none" if no panic.

Hints - runtime.Error interface. - Use type assertion.

Solution

import "runtime"

func Categorize(fn func()) (kind string) {
    kind = "none"
    defer func() {
        r := recover()
        if r == nil {
            return
        }
        if _, ok := r.(runtime.Error); ok {
            kind = "runtime"
            return
        }
        kind = "user"
    }()
    fn()
    return
}


Task 11 (Hard) — Bounded retry with panic recovery

Write RetryWithRecover(attempts int, fn func() error) error that: - Runs fn up to attempts times. - Treats both returned errors and recovered panics as retryable failures. - Returns nil on first success. - Returns the last failure (as an error) after all attempts.

Solution

func RetryWithRecover(attempts int, fn func() error) error {
    var lastErr error
    for i := 0; i < attempts; i++ {
        func() {
            defer func() {
                if r := recover(); r != nil {
                    lastErr = fmt.Errorf("panic on attempt %d: %v", i+1, r)
                }
            }()
            if err := fn(); err != nil {
                lastErr = fmt.Errorf("attempt %d: %w", i+1, err)
                return
            }
            lastErr = nil
        }()
        if lastErr == nil {
            return nil
        }
    }
    return fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}


Task 12 (Hard) — Defer ordering with panic

Write a function that registers three defers, then panics. Show via prints that: - All three defers run. - They run in LIFO order. - The panic value is the same in each defer if you call recover only in the outermost.

Solution

func F() {
    defer fmt.Println("defer 1 (registered first)")
    defer fmt.Println("defer 2")
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("defer 3 recovered:", r)
        }
    }()
    panic("boom")
}

func main() {
    F()
    fmt.Println("after F")
}

// Output:
// defer 3 recovered: boom
// defer 2
// defer 1 (registered first)
// after F


Task 13 (Hard) — Test that a function panics with a specific value

Write a test helper AssertPanics(t *testing.T, fn func(), wantContains string) that fails the test if: - fn does not panic. - The panic value's string representation does not contain wantContains.

Solution

import (
    "fmt"
    "strings"
    "testing"
)

func AssertPanics(t *testing.T, fn func(), wantContains string) {
    t.Helper()
    defer func() {
        r := recover()
        if r == nil {
            t.Fatalf("expected panic containing %q, got none", wantContains)
        }
        s := fmt.Sprintf("%v", r)
        if !strings.Contains(s, wantContains) {
            t.Fatalf("panic %q does not contain %q", s, wantContains)
        }
    }()
    fn()
}

func TestSomething(t *testing.T) {
    AssertPanics(t, func() { panic("nil pointer dereference") }, "nil pointer")
}


Task 14 (Hard) — Recovery middleware with structured logging

Build a middleware that integrates with log/slog and records: - Request ID (from header or generated). - Method, path. - Panic value. - Stack trace.

Solution sketch

import (
    "log/slog"
    "net/http"
    "runtime/debug"
)

func Recover(logger *slog.Logger, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-ID")
        defer func() {
            if rec := recover(); rec != nil {
                logger.Error("panic in handler",
                    "request_id", reqID,
                    "method", r.Method,
                    "path", r.URL.Path,
                    "panic", fmt.Sprint(rec),
                    "stack", string(debug.Stack()),
                )
                http.Error(w, "internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}


Task 15 (Hard) — Supervisor with backoff

Implement Supervise(name string, fn func() error) that: - Runs fn in a loop. - If fn returns an error, log and restart with exponential backoff (1s, 2s, 4s, ..., capped at 60s). - If fn panics, recover, log with stack, treat as a returned error and restart. - Resets backoff to 1s after a successful run that lasts at least one minute.

Solution sketch

func Supervise(name string, fn func() error) {
    backoff := time.Second
    for {
        start := time.Now()
        err := safeCall(name, fn)
        elapsed := time.Since(start)
        if elapsed > time.Minute {
            backoff = time.Second // reset
        }
        log.Printf("supervisor %s: %v; restarting in %v", name, err, backoff)
        time.Sleep(backoff)
        if backoff < time.Minute {
            backoff *= 2
        }
    }
}

func safeCall(name string, fn func() error) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("%s panicked: %v\n%s", name, r, debug.Stack())
        }
    }()
    return fn()
}


Task 16 (Boss-level) — Build a "fault domain" abstraction

Create a FaultDomain type whose Run method: - Executes a task with recover. - Counts panics in a metric. - Returns errors as errors. - Returns panics as *PanicError (a custom error type wrapping the recovered value plus stack). - Provides a Stats() method that returns count of panics per "kind" (string for non-error, error type for errors).

Solution sketch

type PanicError struct {
    Value any
    Stack string
}

func (p *PanicError) Error() string {
    return fmt.Sprintf("panic: %v", p.Value)
}

type FaultDomain struct {
    mu    sync.Mutex
    stats map[string]int
}

func (f *FaultDomain) Run(name string, fn func() error) error {
    var pe *PanicError
    err := func() (err error) {
        defer func() {
            if r := recover(); r != nil {
                pe = &PanicError{Value: r, Stack: string(debug.Stack())}
                err = pe
            }
        }()
        return fn()
    }()
    if pe != nil {
        f.recordPanic(name, pe.Value)
    }
    return err
}

func (f *FaultDomain) recordPanic(name string, v any) {
    f.mu.Lock()
    defer f.mu.Unlock()
    if f.stats == nil { f.stats = map[string]int{} }
    kind := fmt.Sprintf("%T", v)
    f.stats[kind]++
}

func (f *FaultDomain) Stats() map[string]int {
    f.mu.Lock()
    defer f.mu.Unlock()
    out := make(map[string]int, len(f.stats))
    for k, v := range f.stats {
        out[k] = v
    }
    return out
}

This is the kind of building block used in production frameworks (e.g., temporal workers, message queue consumers) to track and react to panic patterns over time.