Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package errors

import (
stderrors "errors"
"fmt"
"runtime"
"strings"

Expand All @@ -10,7 +11,8 @@ import (
)

var (
basePath = ""
basePath = ""
maxStackDepth = 64
)

// StackFrame represents the stackframe for tracing exception
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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,
}
Expand All @@ -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
}
Comment thread
ankurs marked this conversation as resolved.
}

// 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)
Expand Down
90 changes: 89 additions & 1 deletion errors_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package errors

import (
stderrors "errors"
"io"
"testing"

Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}
}

45 changes: 44 additions & 1 deletion notifier/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"

raven "github.com/getsentry/raven-go"
"github.com/go-coldbrew/errors"
Expand All @@ -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)
})
Comment thread
ankurs marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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) {
Expand Down Expand Up @@ -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
}

Expand Down
Loading