diff --git a/errors.go b/errors.go index 77ff402..0e32ac7 100644 --- a/errors.go +++ b/errors.go @@ -2,6 +2,7 @@ package errors import ( stderrors "errors" + "fmt" "runtime" "strings" @@ -10,7 +11,8 @@ import ( ) var ( - basePath = "" + basePath = "" + maxStackDepth = 64 ) // StackFrame represents the stackframe for tracing exception @@ -47,41 +49,47 @@ type customError struct { stack []uintptr frame []StackFrame cause error + wrapped error // immediate parent for Unwrap() chain; may differ from cause shouldNotify bool status *grpcstatus.Status } -// implements notifier.NotifyExt +// ShouldNotify returns true if the error should be reported to notifiers. func (c *customError) ShouldNotify() bool { return c.shouldNotify } -// implements notifier.NotifyExt +// Notified marks the error as having been notified (or not). func (c *customError) Notified(status bool) { c.shouldNotify = !status } -// implements error +// Error returns the error message. func (c customError) Error() string { return c.Msg } +// Callers returns the program counters of the call stack when the error was created. func (c customError) Callers() []uintptr { return c.stack[:] } +// StackTrace returns the program counters of the call stack (alias for Callers). func (c customError) StackTrace() []uintptr { return c.Callers() } +// StackFrame returns the structured stack frames for the error. func (c customError) StackFrame() []StackFrame { return c.frame } +// Cause returns the root cause error that originated this error chain. func (c customError) Cause() error { return c.cause } +// GRPCStatus returns the gRPC status for this error. func (c customError) GRPCStatus() *grpcstatus.Status { if c.status != nil { // use latest error message and keep other data (e.g. details) @@ -97,7 +105,7 @@ func (c customError) GRPCStatus() *grpcstatus.Status { func (c *customError) generateStack(skip int) []StackFrame { stack := []StackFrame{} trace := []uintptr{} - for i := skip + 1; ; i++ { + for i := skip + 1; i < skip+1+maxStackDepth; i++ { pc, file, line, ok := runtime.Caller(i) if !ok { break @@ -118,8 +126,9 @@ func (c *customError) generateStack(skip int) []StackFrame { return stack } +// Unwrap returns the immediate parent error for use with errors.Is and errors.As. func (c customError) Unwrap() error { - return c.cause + return c.wrapped } func packageFuncName(pc uintptr) (string, string) { @@ -199,9 +208,11 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S //if we have stack information reuse that if e, ok := err.(ErrorExt); ok { c := &customError{ - Msg: msg + e.Error(), - cause: e.Cause(), - status: status, + Msg: msg + e.Error(), + cause: e.Cause(), + wrapped: err, // preserve full chain for errors.Is/errors.As + status: status, + shouldNotify: true, } c.stack = e.Callers() @@ -215,6 +226,7 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S c := &customError{ Msg: msg + err.Error(), cause: err, + wrapped: err, shouldNotify: true, status: status, } @@ -223,6 +235,24 @@ func WrapWithSkipAndStatus(err error, msg string, skip int, status *grpcstatus.S } +// SetMaxStackDepth sets the maximum number of stack frames captured when creating errors. +// Default is 64. Must be called during initialization. +func SetMaxStackDepth(n int) { + if n > 0 { + maxStackDepth = n + } +} + +// Newf creates a new error with a formatted message and stack information +func Newf(format string, args ...any) ErrorExt { + return NewWithSkip(fmt.Sprintf(format, args...), 1) +} + +// Wrapf wraps an existing error with a formatted message and appends stack information if it does not exist +func Wrapf(err error, format string, args ...any) ErrorExt { + return WrapWithSkip(err, fmt.Sprintf(format, args...), 1) +} + // SetBaseFilePath sets the base file path for linking source code with reported stack information func SetBaseFilePath(path string) { path = strings.TrimSpace(path) diff --git a/errors_test.go b/errors_test.go index 7e318fd..6e57876 100644 --- a/errors_test.go +++ b/errors_test.go @@ -1,6 +1,7 @@ package errors import ( + stderrors "errors" "io" "testing" @@ -28,7 +29,6 @@ func TestWrap(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { err := Wrap(tt.err, tt.message) if err.Error() != tt.expected { @@ -43,3 +43,91 @@ func TestWrap(t *testing.T) { }) } } + +func TestErrorsIs(t *testing.T) { + // errors.Is should work through the full wrap chain + base := stderrors.New("base error") + wrapped1 := Wrap(base, "layer1") + wrapped2 := Wrap(wrapped1, "layer2") + wrapped3 := Wrap(wrapped2, "layer3") + + if !stderrors.Is(wrapped1, base) { + t.Error("wrapped1 should match base via errors.Is") + } + if !stderrors.Is(wrapped2, wrapped1) { + t.Error("wrapped2 should match wrapped1 via errors.Is") + } + if !stderrors.Is(wrapped2, base) { + t.Error("wrapped2 should match base via errors.Is") + } + if !stderrors.Is(wrapped3, wrapped2) { + t.Error("wrapped3 should match wrapped2 via errors.Is") + } + if !stderrors.Is(wrapped3, wrapped1) { + t.Error("wrapped3 should match wrapped1 via errors.Is") + } + if !stderrors.Is(wrapped3, base) { + t.Error("wrapped3 should match base via errors.Is") + } +} + +func TestCauseStillReturnsRoot(t *testing.T) { + base := stderrors.New("root") + wrapped1 := Wrap(base, "a") + wrapped2 := Wrap(wrapped1, "b") + + // Cause() should still return the root error for backward compatibility + if wrapped1.Cause() != base { + t.Errorf("wrapped1.Cause() = %v, want %v", wrapped1.Cause(), base) + } + if wrapped2.Cause() != base { + t.Errorf("wrapped2.Cause() = %v, want %v", wrapped2.Cause(), base) + } +} + +func TestNewf(t *testing.T) { + err := Newf("error %d: %s", 42, "test") + if err.Error() != "error 42: test" { + t.Errorf("Newf() = %q, want %q", err.Error(), "error 42: test") + } +} + +func TestWrapf(t *testing.T) { + base := stderrors.New("base") + err := Wrapf(base, "context %d", 1) + expected := "context 1: base" + if err.Error() != expected { + t.Errorf("Wrapf() = %q, want %q", err.Error(), expected) + } + if !stderrors.Is(err, base) { + t.Error("Wrapf result should match base via errors.Is") + } +} + +func TestStackDepthCapped(t *testing.T) { + // With default max of 64, stack should never exceed that + err := New("test") + if len(err.StackFrame()) > 64 { + t.Errorf("stack depth %d exceeds max 64", len(err.StackFrame())) + } +} + +func TestStackDepthCappedDeep(t *testing.T) { + var createDeepError func(depth int) ErrorExt + createDeepError = func(depth int) ErrorExt { + if depth == 0 { + return New("deep error") + } + return createDeepError(depth - 1) + } + + // Create error from a stack deeper than 64 + err := createDeepError(100) + if len(err.StackFrame()) > 64 { + t.Errorf("stack depth %d exceeds max 64", len(err.StackFrame())) + } + if len(err.StackFrame()) == 0 { + t.Error("stack should not be empty") + } +} + diff --git a/notifier/notifier.go b/notifier/notifier.go index 297de66..a014ebe 100644 --- a/notifier/notifier.go +++ b/notifier/notifier.go @@ -7,6 +7,7 @@ import ( "runtime" "strconv" "strings" + "sync" raven "github.com/getsentry/raven-go" "github.com/go-coldbrew/errors" @@ -27,12 +28,54 @@ var ( serverRoot string hostname string traceHeader string = "x-trace-id" + + // asyncSem is a semaphore that bounds the number of concurrent async + // notification goroutines. When full, new notifications are dropped + // to prevent goroutine explosion under sustained error bursts. + asyncSem = make(chan struct{}, 1000) ) const ( tracerID = "tracerId" ) +var asyncSemOnce sync.Once + +// SetMaxAsyncNotifications sets the maximum number of concurrent async +// notification goroutines. When the limit is reached, new async notifications +// are dropped to prevent goroutine explosion under sustained error bursts. +// Default is 1000. Can only be called once; subsequent calls are no-ops. +func SetMaxAsyncNotifications(n int) { + if n > 0 { + asyncSemOnce.Do(func() { + asyncSem = make(chan struct{}, n) + }) + } +} + +// NotifyAsync sends an error notification asynchronously with bounded concurrency. +// If the async notification pool is full, the notification is dropped to prevent +// goroutine explosion under sustained error bursts. +// Returns the original error for convenience. +func NotifyAsync(err error, rawData ...interface{}) error { + if err == nil { + return nil + } + sem := asyncSem + select { + case sem <- struct{}{}: + data := append([]interface{}(nil), rawData...) + go func(s chan struct{}, d []interface{}) { + defer func() { <-s }() + _ = Notify(err, d...) + }(sem, data) + default: + // drop notification to prevent goroutine explosion + log.Debug(context.Background(), "msg", "async notification dropped due to capacity", "err", err) + } + return err +} + // SetTraceHeaderName sets the header name for trace id // default is x-trace-id func SetTraceHeaderName(name string) { @@ -325,7 +368,7 @@ func NotifyWithExclude(err error, rawData ...interface{}) error { list = append(list, rawData[pos]) } } - go func() { _ = Notify(err, list...) }() + _ = NotifyAsync(err, list...) return err }