Ein panic ist in Go kein normales Fehler-Werkzeug — es ist der Notausgang für Zustände, die der Code an Ort und Stelle nicht beheben kann. Ohne Gegenmaßnahme reißt jeder unbehandelte panic die gesamte Goroutine mit, und mit ihr im Default-Fall den ganzen Prozess. Das defer + recover-Pattern setzt an genau einer Stelle ein: an der API-Grenze, die ein Aufrufer-System vor dem Crash schützen will. Eine Recovery-Middleware vor allen HTTP-Handlern, ein Wrapper um jede gestartete Worker-Goroutine, ein Konvertierungs-Layer am Eingang einer öffentlichen Library — überall dort verwandelst du den panic in einen geordneten Error-Return. Dieser Artikel ordnet die kanonischen Anwendungsorte, zeigt die idiomatischen Code-Patterns und arbeitet die Stellen heraus, an denen recover nicht die richtige Antwort ist.

Kurz-Wiederholung — panic, recover, defer

Drei eingebaute Funktionen spannen die Mechanik auf. panic(v) stoppt den normalen Kontrollfluss, fängt an, die Goroutine-Stack abzuwickeln, und ruft dabei alle deferred Funktionen auf. recover() darf nur innerhalb einer deferred Funktion sinnvoll aufgerufen werden — dort fängt es den panic-Wert ab und stoppt das Abwickeln, sodass die umgebende Funktion regulär zurückkehren kann. defer ist der Stack-Mechanismus, der beides verbindet: ohne deferred Funktion gibt es keine Stelle, an der recover greifen kann.

Der Go-Blog beschreibt die Regel präzise:

A call to recover stops the unwinding and returns the argument passed to panic. Because the only code that runs while unwinding is inside deferred functions, recover is only useful inside deferred functions.

Die volle Mechanik samt Stack-Traversal liegt im panic-und-recover-Artikel. Wir setzen sie hier voraus und konzentrieren uns auf das Wo und Wie in produktivem Code.

Go erinnerung.go
package main

import "fmt"

func mayPanic() {
    panic("etwas ist schiefgelaufen")
}

func safeCall() {
    // Deferred Closure — der einzige Ort, an dem recover wirkt.
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("aufgefangen:", r)
        }
    }()
    mayPanic()
    fmt.Println("nicht erreicht")
}

func main() {
    safeCall()
    fmt.Println("nach safeCall — Programm laeuft weiter")
}
Output
aufgefangen: etwas ist schiefgelaufen
nach safeCall — Programm laeuft weiter

Das ist das Grundmuster. Alles, was folgt, ist eine Variante davon — mit unterschiedlicher Form, je nachdem, was die Grenze konkret leisten soll.

Die Idee — API-Grenzen als Recovery-Punkt

Ein panic soll Ausnahmesituationen signalisieren: korrupte Datenstrukturen, unmögliche Invariante, Bugs, die nicht lokal reparierbar sind. Was er nicht leisten darf: ein ganzes Server-Prozess zerlegen, weil ein einzelner Request schiefgegangen ist. Genau diese Trennung erzeugt das defer + recover-Pattern. Es definiert eine Grenze — typischerweise eine, an der „eine Arbeitseinheit" anfängt — und stellt sicher, dass ein panic innerhalb dieser Einheit nicht weiter nach außen leckt.

Die kanonischen Grenzen sind immer dieselben drei:

  • Pro Request. Jeder eingehende HTTP-Request, jeder gRPC-Call, jeder Konsumer-Job aus einer Queue ist eine isolierte Arbeitseinheit. Ein panic darin darf den nächsten Request nicht beeinflussen.
  • Pro Goroutine. Eine Goroutine, die ohne recover paniciert, beendet das gesamte Programm — auch wenn andere Goroutines fröhlich weiterarbeiten. Jede selbst gestartete Goroutine braucht ihren eigenen Schutzschirm.
  • Pro Library-Aufruf. Eine öffentliche Library-Funktion sollte einen panic aus ihren inneren Algorithmen (rekursive Parser, Tree-Walks) nicht an den Aufrufer durchreichen, sondern in einen Error verwandeln.

Effective Go fasst die Konvention so zusammen: panic intern erlaubt, aber an der öffentlichen API wandelt man ihn in einen Error. Der Aufrufer sieht nie einen panic — er sieht ein error-Return.

Das kanonische Pattern — Named Return + defer

Die idiomatische Form nutzt einen named return value für den Error. Der Grund ist subtil aber wichtig: die deferred Closure kann auf eine benannte Rückgabe-Variable schreiben, nachdem die umgebende Funktion durch den panic-Abbruch faktisch zurückgekehrt ist.

Go kanonisch.go
package main

import "fmt"

// Wichtig: err ist ein NAMED return — die defer-Closure schreibt
// hier hinein, und der Wert wird tatsaechlich zurueckgegeben.
func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            // panic-Wert in Error umwandeln.
            err = fmt.Errorf("panic in safeDivide: %v", r)
        }
    }()
    return a / b, nil
}

func main() {
    if r, err := safeDivide(10, 2); err == nil {
        fmt.Println("ok:", r)
    }
    if _, err := safeDivide(10, 0); err != nil {
        fmt.Println("fehler:", err)
    }
}
Output
ok: 5
fehler: panic in safeDivide: runtime error: integer divide by zero

Drei Details sind essenziell:

  • err ist named. Ohne Benennung der Rückgabe gibt es keine Variable, in die das defer schreiben könnte. Ein „nacktes" (int, error) ohne Namen funktioniert nicht — der Compiler akzeptiert es, aber err aus der Closure landet nirgendwo.
  • recover() direkt in der Closure. Es muss der unmittelbare Funktions-Body der deferred Funktion sein — recover() in einem Helper, der von der deferred Closure aufgerufen wird, gibt nil zurück. Die Sprach-Spec erlaubt nur den direkten Aufruf.
  • panic-Wert ist any. Üblicherweise ein string, ein error, manchmal etwas Beliebiges. fmt.Errorf("...: %v", r) deckt alle Fälle ab und produziert eine lesbare Meldung.

HTTP-Middleware — Recovery vor allen Handlern

Der mit Abstand häufigste produktive Einsatz: eine Middleware, die jeden HTTP-Handler in einen defer + recover-Block wickelt. Sie sitzt ganz außen im Middleware-Stack (vor Logging, vor Auth, vor Routing — alles, was selbst panicen könnte, soll abgesichert sein), fängt jeden panic auf, loggt ihn mit Stack-Trace und schickt eine kontrollierte 500-Antwort.

Go recovery-middleware-basis.go
package main

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

// Recovery wickelt next so, dass jeder panic in next geloggt und
// in eine 500-Response umgewandelt wird.
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            rec := recover()
            if rec == nil {
                return
            }
            // ErrAbortHandler ist eine bewusste Abbruch-Signalisierung
            // von net/http — wir reichen sie weiter, damit der Server
            // den Default-Cleanup macht.
            if rec == http.ErrAbortHandler {
                panic(rec)
            }
            slog.Error("handler panic",
                "panic", rec,
                "method", r.Method,
                "path", r.URL.Path,
                "stack", string(debug.Stack()),
            )
            http.Error(w, "internal server error", http.StatusInternalServerError)
        }()
        next.ServeHTTP(w, r)
    })
}

Beachte vier Dinge:

  • debug.Stack() liefert den vollen Stack-Trace zum Zeitpunkt des recover. Ohne ihn weißt du was paniciert ist, aber nicht wo.
  • http.ErrAbortHandler ist eine konventionelle Signalisierung, dass der Handler den Request bewusst abbrechen will. Die Recovery-Middleware reicht sie weiter — net/http weiß damit umzugehen und unterdrückt das Logging.
  • http.Error nach recover. Wichtig: nur dann setzen, wenn noch nichts geschrieben wurde. Wer in einem Handler mitten im Body paniciert und schon Bytes gesendet hat, kann nicht mehr „500" obendrauf schreiben — w.WriteHeader ist dann no-op.
  • Strukturiertes Logging. Path, Method, Panic-Wert, Stack — alles separat. Ein log.Println("panic:", rec) ist in einem Produktiv-System wertlos.

net/http hat sein eigenes recover — und das reicht trotzdem nicht

Die net/http-Standard-Bibliothek wickelt jeden Handler intern in einen defer/recover-Block. Wer einen Handler ohne Middleware registriert und einen panic auslöst, sieht das Programm nicht crashen — der Server fängt ihn ab, schließt die Verbindung, loggt eine Zeile mit Stack-Trace nach log.Default() und macht mit dem nächsten Request weiter.

Trotzdem ist eine eigene Recovery-Middleware fast immer richtig. Warum?

  • Kein strukturiertes Logging. Der Built-in nutzt den Default-Logger der Anwendung, format-frei, ohne Request-Kontext (Method, Path, Request-ID). In einer Log-Aggregations-Pipeline ist das nutzlos.
  • Keine kontrollierte Response. Der Server schließt die Verbindung schroff. Der Client sieht keinen sauberen 500-Status mit JSON-Body, sondern ein abgebrochenes Read. Für API-Clients ist das die schlechteste aller Welten.
  • Keine Hooks für Metrics/Tracing. Ein eigenes Middleware kann Prometheus-Counter, Sentry-Capture, OpenTelemetry-Span-Status setzen. Der Built-in tut nichts davon.

Heißt: Der Built-in ist eine Sicherheits-Garantie — er verhindert den Prozess-Crash. Die eigene Middleware ist die Observability-Schicht obendrauf. Beides ist aktiv, beides hat seinen Platz.

Goroutine-Panics — der gefährlichste Fall

Hier liegt die größte Falle. Ein panic in einer Goroutine, die nicht mit recover gesichert ist, beendet das gesamte Programm — nicht nur die Goroutine. Der Go-Runtime kann nicht wissen, ob die anderen Goroutines noch sinnvoll weiterarbeiten können, also bricht er die Sicherheit halber alles ab.

Die HTTP-Recovery-Middleware oben hilft hier nichts. Sie sitzt im Handler-Stack — wenn der Handler eine eigene Goroutine startet und die paniciert, ist der Recover-Frame längst woanders.

Go goroutine-panic.go
package main

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

// FALSCH — startet eine Goroutine ohne Schutz.
// Ein panic darin reisst den gesamten Prozess mit.
func dangerous() {
    go func() {
        panic("im hintergrund")
    }()
}

// RICHTIG — jede gestartete Goroutine wickelt ihren Body in defer + recover.
func safe() {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                slog.Error("goroutine panic",
                    "panic", r,
                    "stack", string(debug.Stack()),
                )
            }
        }()
        // Eigentliche Arbeit hier.
        doWork()
    }()
}

func doWork() { panic("im hintergrund") }

In Code-Reviews ist das die Top-Regel: Jedes go funktion(...) braucht entweder eine eigene recover-Schicht in der gestarteten Funktion, oder die Garantie, dass funktion selbst niemals panicen kann. Letzteres ist in der Praxis kaum haltbar — selbst eine Map-Lookup oder ein Type-Assertion kann panicen.

Üblich ist ein Helper, der genau dieses Wrapping kapselt:

Go go-safe.go
// GoSafe startet f als Goroutine mit eingebautem Panic-Schutz.
func GoSafe(f func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                slog.Error("background panic",
                    "panic", r,
                    "stack", string(debug.Stack()),
                )
            }
        }()
        f()
    }()
}

// Verwendung — sieht aus wie ein normales `go ...`, ist aber abgesichert.
// GoSafe(func() { doWork() })

Worker-Pool — panics einkapseln, Worker neu starten

Ein klassischer Anwendungsfall: eine feste Anzahl Worker-Goroutines liest aus einer Job-Queue. Wenn ein Worker an einem konkreten Job paniciert, soll der Pool als Ganzes weiterlaufen — der eine Job ist verloren (oder landet in einer Dead-Letter-Queue), aber die anderen Worker und nachfolgenden Jobs sind nicht betroffen.

Go worker-pool.go
package main

import (
    "fmt"
    "log/slog"
    "runtime/debug"
    "sync"
)

type Job struct {
    ID      int
    Payload any
}

// processJob ist die Arbeitseinheit — darf panicen.
func processJob(j Job) {
    if j.ID == 7 {
        panic(fmt.Sprintf("job %d ist korrupt", j.ID))
    }
    // ... echte Arbeit
}

// worker liest aus jobs und faengt jeden panic ab.
// Ein panic in processJob soll den naechsten Job NICHT verhindern.
func worker(id int, jobs <-chan Job, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        runJob(id, j)
    }
}

// runJob isoliert den panic-Schutz pro Job. Eine eigene Funktion
// ist wichtig — sonst wuerde der defer-Block fuer den GESAMTEN
// Worker gelten und nur einmal ausgeloest werden.
func runJob(workerID int, j Job) {
    defer func() {
        if r := recover(); r != nil {
            slog.Error("worker panic",
                "worker", workerID,
                "job", j.ID,
                "panic", r,
                "stack", string(debug.Stack()),
            )
        }
    }()
    processJob(j)
}

Der entscheidende Move ist runJob als eigene Funktion. Wer den defer-Block in worker direkt schreibt, vergisst, dass defer + recover die umgebende Funktion zum Rückkehren zwingt. Der recover am Worker-Level fängt zwar den panic, aber die for-Schleife ist gestorben — der Worker liest nie wieder aus dem Channel. Der Pool verliert pro panic einen Worker, irgendwann sind alle weg.

Die richtige Granularität ist pro Arbeitseinheit: jeder Job kriegt seinen eigenen Recovery-Frame. Nach einem panic kehrt runJob regulär zurück, die for-Schleife des Worker holt den nächsten Job aus dem Channel — der Pool bleibt voll besetzt.

Library-API-Boundary — interner panic, externer Error

Bestimmte Algorithmen sind mit panic intern einfacher zu schreiben als mit durchgereichten Errors auf jeder Stufe. Klassische Kandidaten:

  • Rekursive Parser. Ein Syntax-Fehler tief in der Rekursion ist einfacher per panic nach oben zu transportieren als durch zehn Funktionsebenen zu reichen. So macht es die regexp-Library in der Stdlib.
  • Tree-Walker / Visitor. Eine Abort-Bedingung mitten im Walk ist per panic der direkte Notausgang.
  • Generated Code mit Invariant-Checks. Code-Generatoren bauen oft panic-Aufrufe ein, wo sie zur Compile-Zeit nicht wissen, ob die Invariante hält.

Die Konvention: innen panic, außen Error. Die öffentliche API der Library hat einen Wrapper, der den panic abfängt und in einen sauberen Error verwandelt.

Go library-boundary.go
package main

import (
    "errors"
    "fmt"
)

// ErrParse ist der oeffentliche Sentinel-Error fuer Parse-Fehler.
var ErrParse = errors.New("parse error")

// parseError ist ein internes Signal — wird per panic transportiert.
type parseError struct {
    pos int
    msg string
}

// parseExpr ist die interne, rekursive Parser-Funktion.
// Bei einem Syntax-Fehler paniciert sie mit parseError.
func parseExpr(input string, pos int) int {
    if pos >= len(input) {
        panic(parseError{pos: pos, msg: "unerwartetes Ende"})
    }
    // ... echte Parser-Logik (rekursiv)
    return 42
}

// Parse ist die oeffentliche API. Sie wandelt jeden parseError-panic
// in einen Error um — Aufrufer sehen niemals einen panic.
func Parse(input string) (result int, err error) {
    defer func() {
        r := recover()
        if r == nil {
            return
        }
        // Nur unsere eigenen panics fangen — fremde durchreichen.
        pe, ok := r.(parseError)
        if !ok {
            panic(r)
        }
        err = fmt.Errorf("%w at pos %d: %s", ErrParse, pe.pos, pe.msg)
    }()
    return parseExpr(input, 0), nil
}

func main() {
    _, err := Parse("")
    fmt.Println("err:", err)
    fmt.Println("is ErrParse?", errors.Is(err, ErrParse))
}
Output
err: parse error at pos 0: unerwartetes Ende
is ErrParse? true

Zwei wichtige Details:

  • Type-Assertion auf den eigenen Typ. Wir fangen nur parseError. Alles andere — Runtime-Panics wie Nil-Dereferenzierung, Slice-Bounds, oder fremde Library-Panics — reichen wir mit panic(r) weiter. Sonst würden wir echte Bugs in unserer Implementierung schlucken.
  • %w zum Wrappen. errors.Is(err, ErrParse) funktioniert nur, wenn ErrParse korrekt eingebettet ist.

Was tun mit dem panic-Wert

recover() liefert ein any. Was darin steckt, hängt davon ab, was paniciert hat. Runtime-Panics (Nil-Dereferenzierung, Bounds-Verletzung) liefern einen Wert vom Typ runtime.Error. Explizite panic(err)-Aufrufe liefern den übergebenen Wert in seiner ursprünglichen Form. panic("string") ist auch erlaubt und liefert einen string.

Drei Strategien, den Wert in einen Error zu wandeln:

StrategieCodeWann
Generische Konvertierungerr = fmt.Errorf("panic: %v", r)Universell, lesbar, verliert Type-Info
Type-Switchswitch e := r.(type) { case error: ...; case string: ... }Wenn unterschiedliche panic-Quellen unterschiedlich behandelt werden
Eigener Wrapper-Typerr = &PanicError{Value: r, Stack: debug.Stack()}Wenn der Aufrufer Stack-Trace und Original-Wert programmatisch zugreifen können soll

Ein eigener Wrapper-Typ ist die mächtigste Variante — er trägt den ursprünglichen Stack-Trace mit, damit beim Logging am Service-Rand nicht nur der recover-Punkt, sondern auch die panic-Stelle sichtbar bleibt:

Go panic-error-type.go
package main

import (
    "fmt"
    "runtime/debug"
)

// PanicError tragt panic-Wert und Stack-Trace zum Zeitpunkt des
// recover. error.Error() liefert eine kompakte Meldung, GetStack()
// den vollen Trace fuer Logging.
type PanicError struct {
    Value any
    Stack []byte
}

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

func (e *PanicError) Unwrap() error {
    if err, ok := e.Value.(error); ok {
        return err
    }
    return nil
}

// recoverToError ist der Standard-Helfer fuer alle Recovery-Frames.
func recoverToError() error {
    r := recover()
    if r == nil {
        return nil
    }
    return &PanicError{Value: r, Stack: debug.Stack()}
}

Der Unwrap erlaubt errors.Is/As über PanicError hinweg, falls der originale panic-Wert selbst ein error war.

Anti-Patterns — wo recover nicht hingehört

Das Pattern hat klare Anwendungsgrenzen. Vier Misuse-Fälle, die in Code-Reviews durchfallen:

  • recover als normaler Error-Pfad. Wer panic("not found") schreibt und drei Funktionen darüber per recover abfängt, hat den Mechanismus zweckentfremdet. Errors sind Werte — sie werden als Return zurückgegeben. panic ist für unerwartete Situationen, nicht für „file nicht gefunden". Faustregel: Wenn der Caller die Bedingung mit einer normalen if err != nil-Prüfung erwarten kann, ist es ein Error, nicht ein panic.

  • Aggressives Recovern. Eine Library, die in jeder einzelnen exportierten Funktion einen Recovery-Frame hat, verschleiert Bugs. Eine echte Nil-Dereferenzierung wird in einen anonymen Error verwandelt, der Stack-Trace verschwindet, und der Bug bleibt jahrelang unentdeckt. Recovery gehört an wenige, klar definierte Grenzen — nicht überall.

  • Recovery ohne Logging. defer func() { recover() }() ohne Logging und ohne Error-Return ist eine stille Panik-Mauer. Das Programm läuft weiter, aber niemand erfährt, dass etwas kaputt war. In Produktion ist das fatal — Bugs bleiben unsichtbar, Metriken zeigen alles grün, während im Hintergrund Arbeitseinheiten verschluckt werden.

  • panic für Kontrollfluss. „Ich panice mich aus dieser tiefen Schleife raus, ist einfacher als ein boolean-Flag." Das ist ein Code-Smell. Ja, regexp macht es — aber nur, weil der Parser rekursiv ist und das Alternativ-Modell (Error durch zehn Ebenen reichen) tatsächlich unübersichtlicher wäre. In normaler Geschäftslogik ist ein break-Label oder ein early return immer richtig.

Go anti-pattern-stiller-recover.go
// ANTI-PATTERN — schluckt jeden panic ohne Spur.
// In Produktion verschwinden Bugs auf Nimmerwiedersehen.
func silentSwallow() {
    defer func() {
        recover()
    }()
    // ... riskanter Code
}

// RICHTIG — recover protokolliert oder gibt Error zurueck.
func loudRecover() (err error) {
    defer func() {
        if r := recover(); r != nil {
            slog.Error("panic", "value", r, "stack", string(debug.Stack()))
            err = fmt.Errorf("internal: %v", r)
        }
    }()
    // ... riskanter Code
    return nil
}

Praxis — vollständige HTTP-Recovery-Middleware

Eine Produktions-taugliche Recovery-Middleware kombiniert mehrere der bisherigen Bausteine: Type-Assertion auf den panic-Wert, strukturiertes Logging mit Request-Kontext, JSON-Response statt nacktem Text, korrekte Behandlung von http.ErrAbortHandler und ein Hook für Metriken.

Go praxis-recovery-middleware.go
package main

import (
    "encoding/json"
    "fmt"
    "log/slog"
    "net/http"
    "runtime/debug"
    "sync/atomic"
)

// Globaler Counter — in echtem Code waere das ein Prometheus-Counter
// o. Ae. Hier nur zur Demonstration der Hook-Stelle.
var panicCount atomic.Int64

// errorResponse ist das JSON-Format fuer alle Fehler-Antworten.
type errorResponse struct {
    Error     string `json:"error"`
    RequestID string `json:"request_id,omitempty"`
}

// Recovery wickelt next mit voller Panic-Behandlung.
func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                rec := recover()
                if rec == nil {
                    return
                }

                // ErrAbortHandler reichen wir weiter — das ist
                // konventioneller Handler-Abbruch, kein Bug.
                if rec == http.ErrAbortHandler {
                    panic(rec)
                }

                panicCount.Add(1)

                // panic-Wert in error normalisieren.
                var perr error
                switch v := rec.(type) {
                case error:
                    perr = v
                default:
                    perr = fmt.Errorf("%v", v)
                }

                // Request-ID aus Context (von vorgelagerter Middleware).
                reqID, _ := r.Context().Value("request_id").(string)

                logger.Error("handler panic",
                    "request_id", reqID,
                    "method", r.Method,
                    "path", r.URL.Path,
                    "remote", r.RemoteAddr,
                    "error", perr.Error(),
                    "stack", string(debug.Stack()),
                )

                // Antwort nur senden, wenn der Handler noch nichts
                // geschrieben hat. WriteHeader nach erstem Write ist no-op.
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusInternalServerError)
                _ = json.NewEncoder(w).Encode(errorResponse{
                    Error:     "internal server error",
                    RequestID: reqID,
                })
            }()
            next.ServeHTTP(w, r)
        })
    }
}

Diese Middleware ist die äußerste Schicht im Stack. Reihenfolge im Aufbau:

Go middleware-chain.go
// Recovery ganz aussen — alles innen ist abgesichert.
// RequestID vor Recovery, damit der Logger die ID schon hat.
handler := Recovery(logger)(
    RequestID(
        Logging(logger)(
            Auth(
                router,
            ),
        ),
    ),
)

Praxis — selbstheilender Worker-Pool

Zweites Praxis-Beispiel: ein Worker-Pool, der einzelne Job-Panics toleriert und beim Tod eines kompletten Workers (außerhalb des Job-Frames) den Worker automatisch neu startet. Das schützt gegen den selteneren Fall, dass die Worker-Schleife selbst paniciert (z. B. beim Channel-Receive).

Go praxis-worker-pool.go
package main

import (
    "context"
    "fmt"
    "log/slog"
    "runtime/debug"
    "sync"
    "time"
)

type Job struct {
    ID  int
    Bad bool
}

// process simuliert die echte Arbeitsfunktion — kann panicen.
func process(j Job) error {
    if j.Bad {
        panic(fmt.Sprintf("korrupter Job %d", j.ID))
    }
    time.Sleep(10 * time.Millisecond)
    return nil
}

// runJob isoliert den Recovery-Frame pro Job. Sie kehrt immer regulaer
// zurueck — egal ob die Verarbeitung erfolgreich war, einen Error
// geliefert oder paniciert hat.
func runJob(logger *slog.Logger, workerID int, j Job) (err error) {
    defer func() {
        if r := recover(); r != nil {
            logger.Error("job panic",
                "worker", workerID,
                "job", j.ID,
                "panic", r,
                "stack", string(debug.Stack()),
            )
            err = fmt.Errorf("job %d panic: %v", j.ID, r)
        }
    }()
    return process(j)
}

// worker liest aus jobs und verarbeitet sie, bis der Channel zu ist.
func worker(ctx context.Context, logger *slog.Logger, id int, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j, ok := <-jobs:
            if !ok {
                return
            }
            if err := runJob(logger, id, j); err != nil {
                logger.Warn("job failed", "worker", id, "job", j.ID, "err", err)
            }
        }
    }
}

// supervise startet einen Worker und re-startet ihn, falls die
// Worker-Schleife selbst (nicht nur ein einzelner Job) paniciert.
func supervise(ctx context.Context, logger *slog.Logger, id int, jobs <-chan Job, maxRestarts int, wg *sync.WaitGroup) {
    defer wg.Done()
    for attempt := 0; attempt <= maxRestarts; attempt++ {
        func() {
            defer func() {
                if r := recover(); r != nil {
                    logger.Error("worker loop panic — restarting",
                        "worker", id,
                        "attempt", attempt,
                        "panic", r,
                        "stack", string(debug.Stack()),
                    )
                }
            }()
            worker(ctx, logger, id, jobs)
        }()
        select {
        case <-ctx.Done():
            return
        default:
        }
    }
    logger.Error("worker gave up after restarts", "worker", id, "max", maxRestarts)
}

Zwei Schichten Recovery sind ineinander verschachtelt:

  • Innen — runJob fängt panics einzelner Jobs. Der Worker liest danach den nächsten Job, der Pool bleibt voll besetzt.
  • Außen — supervise fängt panics der Worker-Schleife selbst. Falls ein Bug im Channel-Receive oder im Select-Block einen panic auslöst, wird der Worker mit einem neuen Frame neu gestartet. Nach maxRestarts Versuchen gibt die Supervision auf — das verhindert endlose Re-Start-Loops bei deterministischen Bugs.

Interessantes

recover wirkt nur in direkt deferred Closures.

Ein recover()-Aufruf in einer Hilfsfunktion, die aus der deferred Closure heraus aufgerufen wird, liefert immer nil. Die Sprach-Spec verlangt den direkten Aufruf im deferred Funktions-Body. Wer einen wiederverwendbaren Recovery-Helper bauen will, muss die deferred Closure selbst inlinen oder einen Helper schreiben, der die deferred Funktion für dich registriert — und nicht selbst das recover ruft.

Named Return ist Pflicht, um Errors aus recover zu setzen.

Ohne Benennung des Error-Return gibt es keine Variable, in die die deferred Closure schreiben könnte. func f() (err error) funktioniert, func f() error nicht — der zweite hat einen unbenannten Return-Slot, den die Closure nicht erreicht. Compiler warnt nicht, also genau hinsehen.

Jede Goroutine braucht eigenes recover oder garantiert panic-freie Logik.

Ein nicht-recovered panic in einer Goroutine reißt den gesamten Prozess mit — auch alle anderen Goroutines. Eine Recovery-Middleware im HTTP-Handler hilft hier nichts, weil sie auf der Handler-Goroutine sitzt. Standard-Defensive: ein GoSafe-Wrapper, der jede Goroutine in defer + recover wickelt.

net/http hat eingebauten Recover — Logging ist trotzdem unzureichend.

Der Server schützt vor dem Prozess-Crash, loggt mit log.Default() ohne Request-Kontext, schließt die Verbindung schroff. Für produktive APIs braucht es eine eigene Middleware mit strukturiertem Logger, Stack-Trace und sauberer 500-JSON-Response. Den Built-in als Sicherheitsnetz drunter behalten — er kostet nichts.

http.ErrAbortHandler ist Kontrollfluss, kein Bug — durchreichen.

Wenn dein Handler panic(http.ErrAbortHandler) macht (z. B. um eine bereits gestartete Response abzubrechen), darf deine Recovery-Middleware das nicht in eine 500 verwandeln. Erkennen, mit panic(rec) weitergeben, der Server macht den Rest.

Recovery-Granularität pro Arbeitseinheit, nicht pro Worker.

Ein Worker-Pool muss den defer + recover-Block pro Job haben, nicht pro Worker. Ein recover am Worker-Level fängt zwar den ersten panic, aber die Worker-Schleife endet — der Worker liest nie wieder. Lösung: jede Arbeitseinheit in eine eigene Funktion mit eigenem defer-Block kapseln.

debug.Stack() rettet die Debugging-Information.

Ohne debug.Stack() weißt du nach dem recover was paniciert ist, aber nicht wo im Code. Der originale Stack ist mit dem Unwinding bereits zerstört, wenn nur der panic-Wert in einen Error gewandelt wird. Immer mitloggen — als String oder in einem eigenen PanicError-Typ.

Stiller recover ohne Logging ist die schlimmste Variante.

defer func() { recover() }() ohne weitere Behandlung schluckt jeden panic spurlos. In Produktion verschwinden Bugs jahrelang, Metriken zeigen alles grün. Jeder Recovery-Frame braucht entweder strukturiertes Logging, einen Error-Return oder beides — sonst entsteht eine unsichtbare Fehler-Senke.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Error-Handling

Zur Übersicht