-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patherrors.go
More file actions
473 lines (383 loc) · 11.7 KB
/
errors.go
File metadata and controls
473 lines (383 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
package ewrap
import (
"errors"
"fmt"
"maps"
"runtime"
"strings"
"sync"
)
const (
baseLogDataSize = 4
// defaultStackDepth is the default number of frames captured when no
// override is supplied via WithStackDepth.
defaultStackDepth = 32
// callerSkipNew is the number of frames runtime.Callers should skip so
// the captured stack starts at the user's call site rather than inside
// ewrap. Tuned for direct calls to New / Wrap / Newf / Wrapf.
callerSkipNew = 3
)
// Error represents a custom error type with stack trace and structured metadata.
//
// Fields populated by package-provided options (ErrorContext, RecoverySuggestion,
// RetryInfo) are stored in dedicated typed fields so they cannot collide with
// arbitrary user-supplied metadata keys. The formatted error string and stack
// trace are computed lazily and cached on first access.
type Error struct {
msg string
cause error
stack []uintptr
metadata map[string]any
errorContext *ErrorContext
recovery *RecoverySuggestion
retry *RetryInfo
logger Logger
observer Observer
// httpStatus carries an HTTP status code attached via WithHTTPStatus.
// Zero means unset.
httpStatus int
// retryable holds an explicit retry classification (tri-state via pointer:
// nil = not classified, &true / &false = explicit).
retryable *bool
// safeMsg is a redacted variant of msg returned by SafeError when set.
safeMsg string
// fullMsg is set when msg already includes the cause text (e.g. constructed
// via Newf with %w). When true, Error() returns msg verbatim.
fullMsg bool
// mu protects metadata mutation and retry mutation. Cached strings use
// sync.Once so they need no separate lock.
mu sync.RWMutex
// Cached lazy outputs. errOnce/errStr cache Error(); stackOnce/stackStr
// cache the formatted stack trace.
errOnce sync.Once
errStr string
stackOnce sync.Once
stackStr string
}
// Option defines the signature for configuration options.
type Option func(*Error)
// WithLogger sets a logger for the error.
func WithLogger(log Logger) Option {
return func(err *Error) {
err.logger = log
if log != nil {
log.Debug("error created",
"message", err.msg,
"stack", err.Stack(),
)
}
}
}
// WithObserver sets an observer for the error.
func WithObserver(observer Observer) Option {
return func(err *Error) {
err.observer = observer
}
}
// WithStackDepth overrides the default number of stack frames captured.
// Pass 0 to disable stack capture entirely; clamps to a minimum of 1
// otherwise. Must be supplied at construction time (it has no effect on
// already-constructed errors).
func WithStackDepth(depth int) Option {
return func(err *Error) {
if depth <= 0 {
err.stack = nil
return
}
err.stack = capturePCs(callerSkipNew, depth)
}
}
// New creates a new Error with a stack trace and applies the provided options.
func New(msg string, opts ...Option) *Error {
return newAt(callerSkipNew, msg, opts...)
}
// NewSkip is like New but skips an additional frames stack frames so callers
// wrapping New in a helper see their own location captured.
func NewSkip(skip int, msg string, opts ...Option) *Error {
return newAt(callerSkipNew+skip, msg, opts...)
}
func newAt(skip int, msg string, opts ...Option) *Error {
err := &Error{
msg: msg,
stack: capturePCs(skip, defaultStackDepth),
}
for _, opt := range opts {
opt(err)
}
return err
}
// Newf creates a new Error with a formatted message.
//
// If format contains the %w verb, the matching argument is preserved as the
// error's cause so that errors.Is/As walk through it. The resulting Error
// behaves like fmt.Errorf with respect to message text and unwrap chain.
func Newf(format string, args ...any) *Error {
return newfAt(callerSkipNew, format, args...)
}
func newfAt(skip int, format string, args ...any) *Error {
if !strings.Contains(format, "%w") {
return newAt(skip+1, fmt.Sprintf(format, args...))
}
// fmt.Errorf is the only way to extract the cause produced by %w; the
// dynamic error it returns is transient and never escapes this function.
formatted := fmt.Errorf(format, args...) //nolint:err113 // intentional %w extraction
var cause error
if u, ok := formatted.(interface{ Unwrap() error }); ok {
cause = u.Unwrap()
} else if u, ok := formatted.(interface{ Unwrap() []error }); ok {
if causes := u.Unwrap(); len(causes) > 0 {
cause = causes[0]
}
}
return &Error{
msg: formatted.Error(),
cause: cause,
stack: capturePCs(skip+1, defaultStackDepth),
fullMsg: true,
}
}
// Wrap wraps an existing error with additional context, capturing a stack
// trace at the wrap site. Returns nil if err is nil.
//
// When the wrapped error is itself an *Error, the wrapper inherits its
// metadata, error context, recovery suggestion, retry info, logger and
// observer. Each wrapper retains its own stack frames so deep chains carry
// the full call history.
func Wrap(err error, msg string, opts ...Option) *Error {
return wrapAt(callerSkipNew, err, msg, opts...)
}
// WrapSkip is like Wrap but skips an additional skip stack frames.
func WrapSkip(skip int, err error, msg string, opts ...Option) *Error {
return wrapAt(callerSkipNew+skip, err, msg, opts...)
}
func wrapAt(skip int, err error, msg string, opts ...Option) *Error {
if err == nil {
return nil
}
wrapped := &Error{
msg: msg,
cause: err,
stack: capturePCs(skip, defaultStackDepth),
}
var inner *Error
if errors.As(err, &inner) {
inner.mu.RLock()
if len(inner.metadata) > 0 {
wrapped.metadata = maps.Clone(inner.metadata)
}
wrapped.errorContext = inner.errorContext
wrapped.recovery = inner.recovery
wrapped.retry = inner.retry
wrapped.observer = inner.observer
wrapped.logger = inner.logger
wrapped.httpStatus = inner.httpStatus
wrapped.retryable = inner.retryable
inner.mu.RUnlock()
}
for _, opt := range opts {
opt(wrapped)
}
return wrapped
}
// Wrapf wraps an error with a formatted message.
func Wrapf(err error, format string, args ...any) *Error {
if err == nil {
return nil
}
return wrapAt(callerSkipNew, err, fmt.Sprintf(format, args...))
}
// Error implements the error interface. The result is computed once on first
// call and cached; subsequent calls are lock-free reads.
func (e *Error) Error() string {
e.errOnce.Do(func() {
switch {
case e.fullMsg, e.cause == nil:
e.errStr = e.msg
default:
e.errStr = e.msg + ": " + e.cause.Error()
}
})
return e.errStr
}
// Cause returns the underlying cause of the error.
func (e *Error) Cause() error {
return e.cause
}
// WithMetadata adds metadata to the error.
//
// The key namespace is reserved for user data; package-managed values (error
// context, recovery suggestion, retry info) live in dedicated accessors.
func (e *Error) WithMetadata(key string, value any) *Error {
e.mu.Lock()
if e.metadata == nil {
e.metadata = make(map[string]any)
}
e.metadata[key] = value
log := e.logger
e.mu.Unlock()
if log != nil {
log.Debug("metadata added",
"key", key,
"value", value,
"error", e.msg,
)
}
return e
}
// WithContext attaches an existing ErrorContext to the error.
func (e *Error) WithContext(ctx *ErrorContext) *Error {
e.errorContext = ctx
if e.logger != nil {
e.logger.Debug("context added",
"context", ctx,
"error", e.msg,
)
}
return e
}
// WithRecoverySuggestion attaches recovery guidance to the error.
func WithRecoverySuggestion(rs *RecoverySuggestion) Option {
return func(err *Error) {
err.recovery = rs
if err.logger != nil && rs != nil {
logData := []any{"message", rs.Message}
if len(rs.Actions) > 0 {
logData = append(logData, "actions", rs.Actions)
}
if rs.Documentation != "" {
logData = append(logData, "documentation", rs.Documentation)
}
err.logger.Info("recovery suggestion added", logData...)
}
}
}
// GetMetadata retrieves user-defined metadata from the error.
func (e *Error) GetMetadata(key string) (any, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
val, ok := e.metadata[key]
return val, ok
}
// GetMetadataValue retrieves user-defined metadata and casts it to type T.
func GetMetadataValue[T any](e *Error, key string) (T, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
var zero T
val, ok := e.metadata[key]
if !ok {
return zero, false
}
typedVal, ok := val.(T)
if !ok {
return zero, false
}
return typedVal, true
}
// GetErrorContext returns the structured error context, or nil if none was set.
func (e *Error) GetErrorContext() *ErrorContext {
return e.errorContext
}
// Recovery returns the recovery suggestion attached to the error, or nil.
func (e *Error) Recovery() *RecoverySuggestion {
return e.recovery
}
// Retry returns the retry information attached to the error, or nil.
func (e *Error) Retry() *RetryInfo {
return e.retry
}
// Stack returns the stack trace as a string, with runtime and ewrap-package
// frames filtered out so callers see their own code first. The result is
// computed once and cached.
func (e *Error) Stack() string {
e.stackOnce.Do(func() {
if len(e.stack) == 0 {
return
}
var builder strings.Builder
frames := runtime.CallersFrames(e.stack)
for {
frame, more := frames.Next()
if !isInternalFrame(frame) {
_, _ = fmt.Fprintf(&builder, "%s:%d - %s\n", frame.File, frame.Line, frame.Function)
}
if !more {
break
}
}
e.stackStr = builder.String()
})
return e.stackStr
}
// Log logs the error using the configured logger.
func (e *Error) Log() {
if e.observer != nil {
e.observer.RecordError(e.msg)
}
if e.logger == nil {
return
}
e.mu.RLock()
logData := make([]any, 0, len(e.metadata)*2+baseLogDataSize)
logData = append(logData, "error", e.msg)
if e.cause != nil {
logData = append(logData, "cause", e.cause.Error())
}
logData = append(logData, "stack", e.Stack())
for key, val := range e.metadata {
logData = append(logData, key, val)
}
e.mu.RUnlock()
if e.recovery != nil {
logData = appendRecoverySuggestion(logData, e.recovery)
}
e.logger.Error("error occurred", logData...)
}
// CaptureStack captures the current stack trace at the call site using the
// default depth.
func CaptureStack() []uintptr {
return capturePCs(callerSkipNew, defaultStackDepth)
}
// capturePCs returns the program counters of the current call stack starting
// skip frames up. The slice is sized to depth so callers with shallow stacks
// don't carry empty trailing slots.
func capturePCs(skip, depth int) []uintptr {
if depth <= 0 {
return nil
}
pcs := make([]uintptr, depth)
n := runtime.Callers(skip, pcs)
return pcs[:n]
}
// Unwrap provides compatibility with Go 1.13 error chains. errors.Is and
// errors.As walk the chain via this method; the package-level Is method is
// intentionally not implemented so the stdlib semantics apply unchanged.
func (e *Error) Unwrap() error {
return e.cause
}
// isInternalFrame returns true for frames the user shouldn't see in a stack
// trace: runtime internals and ewrap's own non-test implementation. Test
// files in the same package are allowed through so users running ewrap's
// own tests still see useful traces.
func isInternalFrame(frame runtime.Frame) bool {
if strings.HasPrefix(frame.Function, "runtime.") {
return true
}
if !strings.HasPrefix(frame.Function, "github.com/hyp3rd/ewrap.") {
return false
}
if strings.HasSuffix(frame.File, "_test.go") {
return false
}
return true
}
// appendRecoverySuggestion extracts recovery suggestion data for logging.
func appendRecoverySuggestion(logData []any, rs *RecoverySuggestion) []any {
logData = append(logData, "recovery_message", rs.Message)
if len(rs.Actions) > 0 {
logData = append(logData, "recovery_actions", rs.Actions)
}
if rs.Documentation != "" {
logData = append(logData, "recovery_documentation", rs.Documentation)
}
return logData
}