Documentation
¶
Overview ¶
Package panics is the minimal representation of a recovered panic.
A library that runs code it did not write has to decide what a panic in that code becomes. This package holds one answer for all of them to share: what was passed to panic, and where it happened.
if err := panics.Catch(userCallback); err != nil {
// errors.Is(err, panics.ErrPanic) holds.
}
Stacks are kept as program counters and symbolicated only when something renders them, so a panic that is checked and discarded never pays for the expensive part.
There are deliberately no formatters here, no global state, no slog integration, and no slot for a package-specific sentinel or call site. Rendering belongs to an error-wrapping library. Classifying a panic and naming its site belong to whoever caught it.
Panic is meant to be a shared errors.As target, and a v2 would split the dependency graph between the modules reaching for it. The v1 API is frozen and grows only additively.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrPanic = errors.New("panic")
ErrPanic marks every Panic. It is the one check that holds across every package that recovers through this one:
if errors.Is(err, panics.ErrPanic) { ... }
A package with its own sentinel wraps this one rather than replacing it, so both checks hold on the same error.
Functions ¶
func Catch ¶
func Catch(fn func()) error
Catch runs fn and contains a panic raised by it, returning the panic as an error or nil:
if err := panics.Catch(func() { observer.Published(ctx, delivery, err) }); err != nil {
report(err)
}
A library that runs code it did not write is the intended caller. The panic stops at this boundary instead of reaching the runtime and taking every other in-flight operation with it.
The return type is error and not *Panic because the result flows onward, where `var err error = Catch(fn)` would otherwise store a nil pointer in a non-nil interface. Callers that need the frames use As.
func CatchError ¶
CatchError runs fn and returns its error, or, if fn panicked, the recovered panic as an error. It is the result-carrying form of Catch, for callers whose function already reports failure the ordinary way. A returned error is passed through untouched, so errors.Is(err, ErrPanic) distinguishes the two outcomes.
A panic is reported even when its value is nil. Go 1.21 turned panic(nil) into a *runtime.PanicNilError, but GODEBUG=panicnil=1 and a main module whose go directive predates 1.21 both restore the old behaviour, where recover() yields nil for a panic that really happened. Such a panic reports as "panic: <nil>", the same rendering a nil Value has anywhere else.
Types ¶
type Panic ¶
type Panic struct {
Value any
// contains filtered or unexported fields
}
Panic is a recovered panic: the value passed to panic, and the stack it came from.
The stack is a field rather than part of the message. An error string is one line by convention, and a multi-kilobyte stack inside one fragments every log aggregator that treats a line as a record.
Keeping the stack out only makes that convention possible to hold. Error renders Value verbatim, so a panic value containing a newline still produces a message with one. Value is the raw access point for a caller that needs to escape or flatten it.
func As ¶
As returns the outermost *Panic in err's chain, and whether there was one. It reaches through package wrappers, so a caller holding an error from any package that recovers through this one can get to Value and StackTrace.
Outermost means the first one the walk meets coming from err, which is what errors.As reports for any other type and is the panic that was contained: one *Panic per recovery, and the wrappers around it belong to whoever caught it. A chain holding more than one is pathological, a recovered panic re-raised and recovered again, and there the result describes the outer recovery, whose Value is the inner *Panic. Unwrap keeps the inner one reachable for a caller that cares.
A typed-nil *Panic is skipped rather than reported. A type assertion succeeds on one, so reporting it would make As report success and hand back a pointer that nil-derefs on the very first Value read. The walk then carries on past the typed nil to whatever else the chain holds, because a caller told by Is that the error is a panic must be able to reach that panic through As.
The walk follows Unwrap() error and Unwrap() []error, and those only. A wrapper that offers no Unwrap and reaches its cause solely through an As(any) bool method of its own is therefore opaque here. It is equally opaque to Is, since errors.Is does not consult that hook either. errors.As sees straight through it; both of this package's questions answer false.
Honouring the hook would mean delegating the match, which is the thing the paragraph above exists to avoid: whatever the hook hands back is reported, typed nil included. A wrapper that provides Unwrap alongside its hook, as almost all do, is walked normally.
func Recover ¶
Recover converts a value from recover() into a *Panic carrying the panicking goroutine's stack:
defer func() {
if p := panics.Recover(recover()); p != nil {
err = p
}
}()
Recover must be called while the panic is still unwinding, meaning from inside a deferred function, at any nesting depth. Called after that function has returned, it captures the surrounding call stack instead of the panic site. Recover(nil) returns nil, so the guard above is all a caller has to write.
The recover() builtin is stricter: it has to be called directly by the deferred function. One call deeper it returns nil, this function returns nil with it, the panic keeps unwinding, and the process dies. That version compiles and passes any test that does not actually panic, so only the call to Recover may move:
// Works: recover() sits in the deferred function, Recover is a frame deeper.
defer func() {
recovered := recover()
err = translate(panics.Recover(recovered))
}()
The result is a concrete *Panic rather than an error because it is examined immediately by the nil check above. Catch and CatchError return error, since their results flow onward.
That makes the nil check the caller's to write, and skipping it is worse than it looks: `var err error = Recover(recover())` after a clean run holds a nil *Panic in a non-nil interface, so err != nil reports a failure, err.Error() prints "panic: <nil>", and both Is and As deny it is a panic. Assign through the check, or use Catch, which returns error for exactly this reason.
func (*Panic) Error ¶
Error renders the panic as "panic: <value>". A nil *Panic renders as "panic: <nil>", which is what a nil Value renders as too.
func (*Panic) StackTrace ¶
StackTrace returns the program counters of the frames the panic came from, innermost first, ready for runtime.CallersFrames.
The frames the panic came from, and no others: the runtime's way into the panic is trimmed off the top, and this package's way out of it off the bottom, along with the caller's own stack below that. So a stack caught by Catch or CatchError starts at the line that called panic and ends at the last frame the panic actually unwound through. It does not lead with runtime.gopanic, and it carries no Catch, no testing.tRunner, no runtime.goexit. A caller who wants their own call site already has it, and an error-wrapping library on top renders it from its own wrap sites.
That promise covers the top of the stack only. The trim removes the runtime's entry into the panic being reported, not every gopanic on it. A deferred function that panics while its own function is already unwinding leaves the first panic's gopanic below the second one's frames, and that frame is a step the panic really took, so it is retained, in the middle.
The trim at the bottom is the one that stands down, and two shapes make it do so. Recover used in a hand-written deferred function is one: nothing of this package sits below the panic there, and only the caller knows where their own guard begins, so the stack runs from the panic site to the bottom of the goroutine. A panic raised inside the guard itself is the other. Catch(nil) faults on the call to fn, so the boundary is the panic site, and cutting there would leave the runtime's fault frames and nothing naming whoever passed the nil; the stack then runs from those fault frames to the bottom instead.
The trim at the top is unaffected by either and keeps running, so neither of those two leads with runtime.gopanic. It has one stand-down of its own, and it is a third shape rather than a case of these. A deferred chain long enough to fill the capture window leaves the panic site outside it, and with nothing to trim to the whole window is kept, gopanic included, at the end of it. See panicSite. Expect that stack to describe the recovery rather than the panic.
The name and signature are the ones sentry-go looks up by reflection, so panic frames reach an APM dashboard without this package depending on any SDK.
The result is a copy, so a caller cannot corrupt the error for whoever reads it next. A nil *Panic has no frames and returns nil.
func (*Panic) Unwrap ¶
Unwrap reports ErrPanic, and the panic value too when it is a usable error. A nil *Panic marks nothing and returns nil, which ends the walk there.
A typed-nil error is withheld: it would satisfy errors.As for its type and then nil-deref in whatever reads it. valueString keeps it visible in the message instead.
The slice is built fresh per call, so an errors.Is or errors.As allocates one small slice for every Panic its walk reaches. It cannot be built once and reused, because Value is exported and mutable: a cached list could name a cause the Panic no longer carries.