Initial QSfera import
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
.idea/
|
||||
tmp/
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Oleku Konko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+1565
File diff suppressed because it is too large
Load Diff
+598
@@ -0,0 +1,598 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog" // Standard structured logging package
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain executes functions sequentially with enhanced error handling.
|
||||
// Logging is optional and configured via a slog.Handler.
|
||||
type Chain struct {
|
||||
steps []chainStep // List of steps to execute
|
||||
errors []error // Accumulated errors during execution
|
||||
config chainConfig // Chain-wide configuration
|
||||
lastStep *chainStep // Pointer to the last added step for configuration
|
||||
logHandler slog.Handler // Optional logging handler (nil means no logging)
|
||||
cancel context.CancelFunc // Function to cancel the context
|
||||
}
|
||||
|
||||
// chainStep represents a single step in the chain.
|
||||
type chainStep struct {
|
||||
execute func() error // Function to execute for this step
|
||||
optional bool // If true, errors don't stop the chain
|
||||
config stepConfig // Step-specific configuration
|
||||
}
|
||||
|
||||
// chainConfig holds chain-wide settings.
|
||||
type chainConfig struct {
|
||||
timeout time.Duration // Maximum duration for the entire chain
|
||||
maxErrors int // Maximum number of errors before stopping (-1 for unlimited)
|
||||
autoWrap bool // Whether to automatically wrap errors with additional context
|
||||
}
|
||||
|
||||
// stepConfig holds configuration for an individual step.
|
||||
type stepConfig struct {
|
||||
context map[string]interface{} // Arbitrary key-value pairs for context
|
||||
category ErrorCategory // Category for error classification
|
||||
code int // Numeric error code
|
||||
retry *Retry // Retry policy for the step
|
||||
logOnFail bool // Whether to log errors automatically
|
||||
metricsLabel string // Label for metrics (not used in this code)
|
||||
logAttrs []slog.Attr // Additional attributes for logging
|
||||
}
|
||||
|
||||
// ChainOption defines a function that configures a Chain.
|
||||
type ChainOption func(*Chain)
|
||||
|
||||
// NewChain creates a new Chain with the given options.
|
||||
// Logging is disabled by default (logHandler is nil).
|
||||
func NewChain(opts ...ChainOption) *Chain {
|
||||
c := &Chain{
|
||||
config: chainConfig{
|
||||
autoWrap: true, // Enable error wrapping by default
|
||||
maxErrors: -1, // No limit on errors by default
|
||||
},
|
||||
// logHandler is nil, meaning no logging unless explicitly configured
|
||||
}
|
||||
// Apply each configuration option
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ChainWithLogHandler sets a custom slog.Handler for logging.
|
||||
// If handler is nil, logging is effectively disabled.
|
||||
func ChainWithLogHandler(handler slog.Handler) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.logHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithTimeout sets a timeout for the entire chain.
|
||||
func ChainWithTimeout(d time.Duration) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.config.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithMaxErrors sets the maximum number of errors allowed.
|
||||
// A value <= 0 means no limit.
|
||||
func ChainWithMaxErrors(max int) ChainOption {
|
||||
return func(c *Chain) {
|
||||
if max <= 0 {
|
||||
c.config.maxErrors = -1 // No limit
|
||||
} else {
|
||||
c.config.maxErrors = max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithAutoWrap enables or disables automatic error wrapping.
|
||||
func ChainWithAutoWrap(auto bool) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.config.autoWrap = auto
|
||||
}
|
||||
}
|
||||
|
||||
// Step adds a new step to the chain with the provided function.
|
||||
// The function must return an error or nil.
|
||||
func (c *Chain) Step(fn func() error) *Chain {
|
||||
if fn == nil {
|
||||
// Panic to enforce valid input
|
||||
panic("Chain.Step: provided function cannot be nil")
|
||||
}
|
||||
// Create a new step with default configuration
|
||||
step := chainStep{execute: fn, config: stepConfig{}}
|
||||
c.steps = append(c.steps, step)
|
||||
// Update lastStep to point to the newly added step
|
||||
c.lastStep = &c.steps[len(c.steps)-1]
|
||||
return c
|
||||
}
|
||||
|
||||
// Call adds a step by wrapping a function with arguments.
|
||||
// It uses reflection to validate and invoke the function.
|
||||
func (c *Chain) Call(fn interface{}, args ...interface{}) *Chain {
|
||||
// Wrap the function and arguments into an executable step
|
||||
wrappedFn, err := c.wrapCallable(fn, args...)
|
||||
if err != nil {
|
||||
// Panic on setup errors to catch them early
|
||||
panic(fmt.Sprintf("Chain.Call setup error: %v", err))
|
||||
}
|
||||
// Add the wrapped function as a step
|
||||
step := chainStep{execute: wrappedFn, config: stepConfig{}}
|
||||
c.steps = append(c.steps, step)
|
||||
c.lastStep = &c.steps[len(c.steps)-1]
|
||||
return c
|
||||
}
|
||||
|
||||
// Optional marks the last step as optional.
|
||||
// Optional steps don't stop the chain on error.
|
||||
func (c *Chain) Optional() *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to mark as optional
|
||||
panic("Chain.Optional: must call Step() or Call() before Optional()")
|
||||
}
|
||||
c.lastStep.optional = true
|
||||
return c
|
||||
}
|
||||
|
||||
// WithLog adds logging attributes to the last step.
|
||||
func (c *Chain) WithLog(attrs ...slog.Attr) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.WithLog: must call Step() or Call() before WithLog()")
|
||||
}
|
||||
// Append attributes to the step's logging configuration
|
||||
c.lastStep.config.logAttrs = append(c.lastStep.config.logAttrs, attrs...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Timeout sets a timeout for the entire chain.
|
||||
func (c *Chain) Timeout(d time.Duration) *Chain {
|
||||
c.config.timeout = d
|
||||
return c
|
||||
}
|
||||
|
||||
// MaxErrors sets the maximum number of errors allowed.
|
||||
func (c *Chain) MaxErrors(max int) *Chain {
|
||||
if max <= 0 {
|
||||
c.config.maxErrors = -1 // No limit
|
||||
} else {
|
||||
c.config.maxErrors = max
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// With adds a key-value pair to the last step's context.
|
||||
func (c *Chain) With(key string, value interface{}) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.With: must call Step() or Call() before With()")
|
||||
}
|
||||
// Initialize context map if nil
|
||||
if c.lastStep.config.context == nil {
|
||||
c.lastStep.config.context = make(map[string]interface{})
|
||||
}
|
||||
// Add the key-value pair
|
||||
c.lastStep.config.context[key] = value
|
||||
return c
|
||||
}
|
||||
|
||||
// Tag sets an error category for the last step.
|
||||
func (c *Chain) Tag(category ErrorCategory) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.Tag: must call Step() or Call() before Tag()")
|
||||
}
|
||||
c.lastStep.config.category = category
|
||||
return c
|
||||
}
|
||||
|
||||
// Code sets a numeric error code for the last step.
|
||||
func (c *Chain) Code(code int) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.Code: must call Step() or Call() before Code()")
|
||||
}
|
||||
c.lastStep.config.code = code
|
||||
return c
|
||||
}
|
||||
|
||||
// Retry configures retry behavior for the last step.
|
||||
// Retry configures retry behavior for the last step.
|
||||
func (c *Chain) Retry(maxAttempts int, delay time.Duration, opts ...RetryOption) *Chain {
|
||||
if c.lastStep == nil {
|
||||
panic("Chain.Retry: must call Step() or Call() before Retry()")
|
||||
}
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
// Define default retry options
|
||||
retryOpts := []RetryOption{
|
||||
WithMaxAttempts(maxAttempts),
|
||||
WithDelay(delay),
|
||||
WithRetryIf(func(err error) bool { return IsRetryable(err) }),
|
||||
}
|
||||
|
||||
// Add logging for retry attempts if a handler is configured
|
||||
if c.logHandler != nil {
|
||||
step := c.lastStep
|
||||
retryOpts = append(retryOpts, WithOnRetry(func(attempt int, err error) {
|
||||
// Prepare logging attributes
|
||||
logAttrs := []slog.Attr{
|
||||
slog.Int("attempt", attempt),
|
||||
slog.Int("max_attempts", maxAttempts),
|
||||
}
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
// Log the retry attempt
|
||||
c.logError(enhancedErr, fmt.Sprintf("Retrying step (attempt %d/%d)", attempt, maxAttempts), step.config, logAttrs...)
|
||||
}))
|
||||
}
|
||||
|
||||
// Append any additional retry options
|
||||
retryOpts = append(retryOpts, opts...)
|
||||
// Create and assign the retry configuration
|
||||
c.lastStep.config.retry = NewRetry(retryOpts...)
|
||||
return c
|
||||
}
|
||||
|
||||
// LogOnFail enables automatic logging of errors for the last step.
|
||||
func (c *Chain) LogOnFail() *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.LogOnFail: must call Step() or Call() before LogOnFail()")
|
||||
}
|
||||
c.lastStep.config.logOnFail = true
|
||||
return c
|
||||
}
|
||||
|
||||
// Run executes the chain, stopping on the first non-optional error.
|
||||
// It returns the first error encountered or nil if all steps succeed.
|
||||
func (c *Chain) Run() error {
|
||||
// Create a context with timeout or cancellation
|
||||
ctx, cancel := c.getContextAndCancel()
|
||||
defer cancel()
|
||||
c.cancel = cancel
|
||||
// Clear any previous errors
|
||||
c.errors = c.errors[:0]
|
||||
|
||||
// Execute each step in sequence
|
||||
for i := range c.steps {
|
||||
step := &c.steps[i]
|
||||
// Check if the context has been canceled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
// Log the context error
|
||||
c.logError(enhancedErr, "Chain stopped due to context error before step", step.config)
|
||||
return enhancedErr
|
||||
default:
|
||||
}
|
||||
|
||||
// Execute the step
|
||||
err := c.executeStep(ctx, step)
|
||||
if err != nil {
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
// Log the error if required
|
||||
if step.config.logOnFail || !step.optional {
|
||||
logMsg := "Chain stopped due to error in step"
|
||||
if step.optional {
|
||||
logMsg = "Optional step failed"
|
||||
}
|
||||
c.logError(enhancedErr, logMsg, step.config)
|
||||
}
|
||||
// Stop execution if the step is not optional
|
||||
if !step.optional {
|
||||
return enhancedErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return nil if all steps completed successfully
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunAll executes all steps, collecting errors without stopping.
|
||||
// It returns a MultiError containing all errors or nil if none occurred.
|
||||
func (c *Chain) RunAll() error {
|
||||
ctx, cancel := c.getContextAndCancel()
|
||||
defer cancel()
|
||||
c.cancel = cancel
|
||||
c.errors = c.errors[:0]
|
||||
multi := NewMultiError()
|
||||
|
||||
for i := range c.steps {
|
||||
step := &c.steps[i]
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
multi.Add(enhancedErr)
|
||||
c.logError(enhancedErr, "Chain stopped due to context error before step (RunAll)", step.config)
|
||||
goto endRunAll
|
||||
default:
|
||||
}
|
||||
|
||||
err := c.executeStep(ctx, step)
|
||||
if err != nil {
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
multi.Add(enhancedErr)
|
||||
if step.config.logOnFail && c.logHandler != nil {
|
||||
c.logError(enhancedErr, "Step failed during RunAll", step.config)
|
||||
}
|
||||
if c.config.maxErrors > 0 && multi.Count() >= c.config.maxErrors {
|
||||
if c.logHandler != nil {
|
||||
// Create a logger to log the max errors condition
|
||||
logger := slog.New(c.logHandler)
|
||||
logger.LogAttrs(
|
||||
context.Background(),
|
||||
slog.LevelError,
|
||||
fmt.Sprintf("Stopping RunAll after reaching max errors (%d)", c.config.maxErrors),
|
||||
slog.Int("max_errors", c.config.maxErrors),
|
||||
)
|
||||
}
|
||||
goto endRunAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endRunAll:
|
||||
return multi.Single()
|
||||
}
|
||||
|
||||
// Errors returns a copy of the collected errors.
|
||||
func (c *Chain) Errors() []error {
|
||||
if len(c.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Create a copy to prevent external modification
|
||||
errs := make([]error, len(c.errors))
|
||||
copy(errs, c.errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// Len returns the number of steps in the chain.
|
||||
func (c *Chain) Len() int {
|
||||
return len(c.steps)
|
||||
}
|
||||
|
||||
// HasErrors checks if any errors were collected.
|
||||
func (c *Chain) HasErrors() bool {
|
||||
return len(c.errors) > 0
|
||||
}
|
||||
|
||||
// LastError returns the most recent error or nil if none exist.
|
||||
func (c *Chain) LastError() error {
|
||||
if len(c.errors) > 0 {
|
||||
return c.errors[len(c.errors)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset clears the chain's steps, errors, and context.
|
||||
func (c *Chain) Reset() {
|
||||
if c.cancel != nil {
|
||||
// Cancel any active context
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
// Clear steps and errors
|
||||
c.steps = c.steps[:0]
|
||||
c.errors = c.errors[:0]
|
||||
c.lastStep = nil
|
||||
}
|
||||
|
||||
// Unwrap returns the collected errors (alias for Errors).
|
||||
func (c *Chain) Unwrap() []error {
|
||||
return c.errors
|
||||
}
|
||||
|
||||
// getContextAndCancel creates a context based on the chain's timeout.
|
||||
// It returns a context and its cancellation function.
|
||||
func (c *Chain) getContextAndCancel() (context.Context, context.CancelFunc) {
|
||||
parentCtx := context.Background()
|
||||
if c.config.timeout > 0 {
|
||||
// Create a context with a timeout
|
||||
return context.WithTimeout(parentCtx, c.config.timeout)
|
||||
}
|
||||
// Create a cancellable context
|
||||
return context.WithCancel(parentCtx)
|
||||
}
|
||||
|
||||
// logError logs an error with step-specific context and attributes.
|
||||
// It only logs if a handler is configured and the error is non-nil.
|
||||
func (c *Chain) logError(err error, msg string, config stepConfig, additionalAttrs ...slog.Attr) {
|
||||
// Skip logging if no handler is set or error is nil
|
||||
if c == nil || c.logHandler == nil || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a logger on demand using the configured handler
|
||||
logger := slog.New(c.logHandler)
|
||||
|
||||
// Initialize attributes with error and timestamp
|
||||
allAttrs := make([]slog.Attr, 0, 5+len(config.logAttrs)+len(additionalAttrs))
|
||||
allAttrs = append(allAttrs, slog.Any("error", err))
|
||||
allAttrs = append(allAttrs, slog.Time("timestamp", time.Now()))
|
||||
|
||||
// Add step-specific metadata
|
||||
if config.category != "" {
|
||||
allAttrs = append(allAttrs, slog.String("category", string(config.category)))
|
||||
}
|
||||
if config.code != 0 {
|
||||
allAttrs = append(allAttrs, slog.Int("code", config.code))
|
||||
}
|
||||
for k, v := range config.context {
|
||||
allAttrs = append(allAttrs, slog.Any(k, v))
|
||||
}
|
||||
allAttrs = append(allAttrs, config.logAttrs...)
|
||||
allAttrs = append(allAttrs, additionalAttrs...)
|
||||
|
||||
// Add stack trace and error name if the error is of type *Error
|
||||
if e, ok := err.(*Error); ok {
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
// Format stack trace, truncating if too long
|
||||
stackStr := "\n\t" + strings.Join(stack, "\n\t")
|
||||
if len(stackStr) > 1000 {
|
||||
stackStr = stackStr[:1000] + "..."
|
||||
}
|
||||
allAttrs = append(allAttrs, slog.String("stacktrace", stackStr))
|
||||
}
|
||||
if name := e.Name(); name != "" {
|
||||
allAttrs = append(allAttrs, slog.String("error_name", name))
|
||||
}
|
||||
}
|
||||
|
||||
// Log the error at ERROR level with all attributes
|
||||
// Use a defer to catch any panics during logging
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Print to stdout to avoid infinite recursion
|
||||
fmt.Printf("ERROR: Recovered from panic during logging: %v\nAttributes: %v\n", r, allAttrs)
|
||||
}
|
||||
}()
|
||||
logger.LogAttrs(context.Background(), slog.LevelError, msg, allAttrs...)
|
||||
}
|
||||
|
||||
// wrapCallable wraps a function and its arguments into an executable step.
|
||||
// It uses reflection to validate the function and arguments.
|
||||
func (c *Chain) wrapCallable(fn interface{}, args ...interface{}) (func() error, error) {
|
||||
val := reflect.ValueOf(fn)
|
||||
typ := val.Type()
|
||||
|
||||
// Ensure the provided value is a function
|
||||
if typ.Kind() != reflect.Func {
|
||||
return nil, fmt.Errorf("provided 'fn' is not a function (got %T)", fn)
|
||||
}
|
||||
// Check if the number of arguments matches the function's signature
|
||||
if typ.NumIn() != len(args) {
|
||||
return nil, fmt.Errorf("function expects %d arguments, but %d were provided", typ.NumIn(), len(args))
|
||||
}
|
||||
|
||||
// Prepare argument values
|
||||
argVals := make([]reflect.Value, len(args))
|
||||
errorType := reflect.TypeOf((*error)(nil)).Elem()
|
||||
for i, arg := range args {
|
||||
expectedType := typ.In(i)
|
||||
var providedVal reflect.Value
|
||||
if arg != nil {
|
||||
providedVal = reflect.ValueOf(arg)
|
||||
// Check if the argument type is assignable to the expected type
|
||||
if !providedVal.Type().AssignableTo(expectedType) {
|
||||
// Special case for error interfaces
|
||||
if expectedType.Kind() == reflect.Interface && expectedType.Implements(errorType) && providedVal.Type().Implements(errorType) {
|
||||
// Allow error interface
|
||||
} else {
|
||||
return nil, fmt.Errorf("argument %d type mismatch: expected %s, got %s", i, expectedType, providedVal.Type())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle nil arguments for nullable types
|
||||
switch expectedType.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
providedVal = reflect.Zero(expectedType)
|
||||
default:
|
||||
return nil, fmt.Errorf("argument %d is nil, but expected non-nillable type %s", i, expectedType)
|
||||
}
|
||||
}
|
||||
argVals[i] = providedVal
|
||||
}
|
||||
|
||||
// Validate the function's return type
|
||||
if typ.NumOut() > 1 || (typ.NumOut() == 1 && !typ.Out(0).Implements(errorType)) {
|
||||
return nil, fmt.Errorf("function must return either no values or a single error (got %d return values)", typ.NumOut())
|
||||
}
|
||||
|
||||
// Return a wrapped function that calls the original with the provided arguments
|
||||
return func() error {
|
||||
results := val.Call(argVals)
|
||||
if len(results) == 1 && results[0].Interface() != nil {
|
||||
return results[0].Interface().(error)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeStep runs a single step, applying retries if configured.
|
||||
// This version is synchronous and avoids the bugs caused by the previous goroutine-based implementation.
|
||||
func (c *Chain) executeStep(ctx context.Context, step *chainStep) error {
|
||||
// First, check if the context has already been canceled before starting the step.
|
||||
// This allows the chain to fail fast.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// Context is still active, proceed.
|
||||
}
|
||||
|
||||
// If the step has retry logic configured...
|
||||
if step.config.retry != nil {
|
||||
// Create a new retry instance that is aware of the chain's context.
|
||||
// The retry executor will be responsible for checking ctx.Done() between attempts.
|
||||
retryExecutor := step.config.retry.Transform(WithContext(ctx))
|
||||
|
||||
// Execute the step's function directly. The retry mechanism will manage the loop,
|
||||
// delays, and context cancellation checks. We pass step.execute without any
|
||||
// extra goroutine wrappers.
|
||||
return retryExecutor.Execute(step.execute)
|
||||
}
|
||||
|
||||
// For a simple, non-retrying step, execute the function directly and synchronously
|
||||
// in the current goroutine. This is the simplest, fastest, and most correct approach.
|
||||
// It ensures that database connections are used and returned to the pool sequentially,
|
||||
// preventing the deadlock issue.
|
||||
return step.execute()
|
||||
}
|
||||
|
||||
// enhanceError wraps an error with additional context from the step.
|
||||
func (c *Chain) enhanceError(err error, step *chainStep) error {
|
||||
if err == nil || !c.config.autoWrap {
|
||||
// Return the error unchanged if nil or autoWrap is disabled
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize the base error
|
||||
var baseError *Error
|
||||
if e, ok := err.(*Error); ok {
|
||||
// Copy existing *Error to preserve its properties
|
||||
baseError = e.Copy()
|
||||
} else {
|
||||
// Create a new *Error wrapping the original
|
||||
baseError = New(err.Error()).Wrap(err).WithStack()
|
||||
}
|
||||
|
||||
if step != nil {
|
||||
// Add step-specific context to the error
|
||||
if step.config.category != "" && baseError.Category() == "" {
|
||||
baseError.WithCategory(step.config.category)
|
||||
}
|
||||
if step.config.code != 0 && baseError.Code() == 0 {
|
||||
baseError.WithCode(step.config.code)
|
||||
}
|
||||
for k, v := range step.config.context {
|
||||
baseError.With(k, v)
|
||||
}
|
||||
for _, attr := range step.config.logAttrs {
|
||||
baseError.With(attr.Key, attr.Value.Any())
|
||||
}
|
||||
if step.config.retry != nil && !baseError.HasContextKey(ctxRetry) {
|
||||
// Mark the error as retryable if retries are configured
|
||||
baseError.WithRetryable()
|
||||
}
|
||||
}
|
||||
|
||||
return baseError
|
||||
}
|
||||
+1474
File diff suppressed because it is too large
Load Diff
+437
@@ -0,0 +1,437 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// As wraps errors.As, using custom type assertion for *Error types.
|
||||
// Falls back to standard errors.As for non-*Error types.
|
||||
// Returns false if either err or target is nil.
|
||||
func As(err error, target interface{}) bool {
|
||||
if err == nil || target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// First try our custom *Error handling
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.As(target)
|
||||
}
|
||||
|
||||
// Fall back to standard errors.As
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// Code returns the status code of an error, if it is an *Error.
|
||||
// Returns 500 as a default for non-*Error types to indicate an internal error.
|
||||
func Code(err error) int {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Code()
|
||||
}
|
||||
return DefaultCode
|
||||
}
|
||||
|
||||
// Context extracts the context map from an error, if it is an *Error.
|
||||
// Returns nil for non-*Error types or if no context is present.
|
||||
func Context(err error) map[string]interface{} {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert transforms any error into an *Error, preserving its message and wrapping it if needed.
|
||||
// Returns nil if the input is nil; returns the original if already an *Error.
|
||||
// Uses multiple strategies: direct assertion, errors.As, manual unwrapping, and fallback creation.
|
||||
func Convert(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First try direct type assertion (fast path)
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e
|
||||
}
|
||||
|
||||
// Try using errors.As (more flexible)
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
|
||||
// Manual unwrapping as fallback
|
||||
visited := make(map[error]bool)
|
||||
for unwrapped := err; unwrapped != nil; {
|
||||
if visited[unwrapped] {
|
||||
break // Cycle detected
|
||||
}
|
||||
visited[unwrapped] = true
|
||||
if e, ok := unwrapped.(*Error); ok {
|
||||
return e
|
||||
}
|
||||
unwrapped = errors.Unwrap(unwrapped)
|
||||
}
|
||||
|
||||
// Final fallback: create new error with original message and wrap it
|
||||
return New(err.Error()).Wrap(err)
|
||||
}
|
||||
|
||||
// Count returns the occurrence count of an error, if it is an *Error.
|
||||
// Returns 0 for non-*Error types.
|
||||
func Count(err error) uint64 {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Count()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find searches the error chain for the first error matching pred.
|
||||
// Returns nil if no match is found or pred is nil; traverses both Unwrap() and Cause() chains.
|
||||
func Find(err error, pred func(error) bool) error {
|
||||
for current := err; current != nil; {
|
||||
if pred(current) {
|
||||
return current
|
||||
}
|
||||
|
||||
// Attempt to unwrap using Unwrap() or Cause()
|
||||
switch v := current.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
current = v.Unwrap()
|
||||
case interface{ Cause() error }:
|
||||
current = v.Cause()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// From transforms any error into an *Error, preserving its message and wrapping it if needed.
|
||||
// Alias of Convert; returns nil if input is nil, original if already an *Error.
|
||||
func From(err error) *Error {
|
||||
return Convert(err)
|
||||
}
|
||||
|
||||
// FromContext creates an *Error from a context and an existing error.
|
||||
// Enhances the error with context info: timeout status, deadline, or cancellation.
|
||||
// Returns nil if input error is nil; does not store context values directly.
|
||||
func FromContext(ctx context.Context, err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e := New(err.Error())
|
||||
|
||||
// Handle context errors
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
e.WithTimeout()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
e.With("deadline", deadline.Format(time.RFC3339))
|
||||
}
|
||||
case context.Canceled:
|
||||
e.With("cancelled", true)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Category returns the category of an error, if it is an *Error.
|
||||
// Returns an empty string for non-*Error types or unset categories.
|
||||
func Category(err error) string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.category
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Has checks if an error contains meaningful content.
|
||||
// Returns true for non-nil standard errors or *Error with content (msg, name, template, or cause).
|
||||
func Has(err error) bool {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Has()
|
||||
}
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// HasContextKey checks if the error's context contains the specified key.
|
||||
// Returns false for non-*Error types or if the key is not present in the context.
|
||||
func HasContextKey(err error, key string) bool {
|
||||
if e, ok := err.(*Error); ok {
|
||||
ctx := e.Context()
|
||||
if ctx != nil {
|
||||
_, exists := ctx[key]
|
||||
return exists
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Is wraps errors.Is, using custom matching for *Error types.
|
||||
// Falls back to standard errors.Is for non-*Error types; returns true if err equals target.
|
||||
func Is(err, target error) bool {
|
||||
if err == nil || target == nil {
|
||||
return err == target
|
||||
}
|
||||
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Is(target)
|
||||
}
|
||||
|
||||
// Use standard errors.Is for non-Error types
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// IsError checks if an error is an instance of *Error.
|
||||
// Returns true only for this package's custom error type; false for nil or other types.
|
||||
func IsError(err error) bool {
|
||||
_, ok := err.(*Error)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEmpty checks if an error has no meaningful content.
|
||||
// Returns true for nil errors, empty *Error instances, or standard errors with whitespace-only messages.
|
||||
func IsEmpty(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.IsEmpty()
|
||||
}
|
||||
return strings.TrimSpace(err.Error()) == ""
|
||||
}
|
||||
|
||||
// IsNull checks if an error is nil or represents a NULL value.
|
||||
// Delegates to *Error’s IsNull for custom errors; uses sqlNull for others.
|
||||
func IsNull(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.IsNull()
|
||||
}
|
||||
return sqlNull(err)
|
||||
}
|
||||
|
||||
// IsRetryable checks if an error is retryable.
|
||||
// For *Error, checks context for retry flag; for others, looks for "retry" or timeout in message.
|
||||
// Returns false for nil errors; thread-safe for *Error types.
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
// Check smallContext directly if context map isn’t populated
|
||||
for i := int32(0); i < e.smallCount; i++ {
|
||||
if e.smallContext[i].key == ctxRetry {
|
||||
if val, ok := e.smallContext[i].value.(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check regular context
|
||||
if e.context != nil {
|
||||
if val, ok := e.context[ctxRetry].(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
// Check cause recursively
|
||||
if e.cause != nil {
|
||||
return IsRetryable(e.cause)
|
||||
}
|
||||
}
|
||||
lowerMsg := strings.ToLower(err.Error())
|
||||
return IsTimeout(err) || strings.Contains(lowerMsg, "retry")
|
||||
}
|
||||
|
||||
// IsTimeout checks if an error indicates a timeout.
|
||||
// For *Error, checks context for timeout flag; for others, looks for "timeout" in message.
|
||||
// Returns false for nil errors.
|
||||
func IsTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
if val, ok := e.Context()[ctxTimeout].(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
// Merge combines multiple errors into a single *Error.
|
||||
// Aggregates messages with "; " separator, merges contexts and stacks; returns nil if no errors provided.
|
||||
func Merge(errs ...error) *Error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var messages []string
|
||||
combined := New("")
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, err.Error())
|
||||
if e, ok := err.(*Error); ok {
|
||||
if e.stack != nil && combined.stack == nil {
|
||||
combined.WithStack() // Capture stack from first *Error with stack
|
||||
}
|
||||
if ctx := e.Context(); ctx != nil {
|
||||
for k, v := range ctx {
|
||||
combined.With(k, v)
|
||||
}
|
||||
}
|
||||
if e.cause != nil {
|
||||
combined.Wrap(e.cause)
|
||||
}
|
||||
} else {
|
||||
combined.Wrap(err)
|
||||
}
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
combined.msg = strings.Join(messages, "; ")
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
// Name returns the name of an error, if it is an *Error.
|
||||
// Returns an empty string for non-*Error types or unset names.
|
||||
func Name(err error) string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UnwrapAll returns a slice of all errors in the chain, including the root error.
|
||||
// Traverses both Unwrap() and Cause() chains; returns nil if err is nil.
|
||||
func UnwrapAll(err error) []error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.UnwrapAll()
|
||||
}
|
||||
var result []error
|
||||
Walk(err, func(e error) {
|
||||
result = append(result, e)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Stack extracts the stack trace from an error, if it is an *Error.
|
||||
// Returns nil for non-*Error types or if no stack is present.
|
||||
func Stack(err error) []string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Stack()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transform applies transformations to an error, returning a new *Error.
|
||||
// Creates a new *Error from non-*Error types before applying fn; returns nil if err is nil.
|
||||
func Transform(err error, fn func(*Error)) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
newErr := e.Copy()
|
||||
fn(newErr)
|
||||
return newErr
|
||||
}
|
||||
// If not an *Error, create a new one and transform it
|
||||
newErr := New(err.Error())
|
||||
fn(newErr)
|
||||
return newErr
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause of an error, if it implements Unwrap.
|
||||
// For *Error, returns cause; for others, returns the error itself; nil if err is nil.
|
||||
func Unwrap(err error) error {
|
||||
for current := err; current != nil; {
|
||||
if e, ok := current.(*Error); ok {
|
||||
if e.cause == nil {
|
||||
return current
|
||||
}
|
||||
current = e.cause
|
||||
} else {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Walk traverses the error chain, applying fn to each error.
|
||||
// Supports both Unwrap() and Cause() interfaces; stops at nil or non-unwrappable errors.
|
||||
func Walk(err error, fn func(error)) {
|
||||
for current := err; current != nil; {
|
||||
fn(current)
|
||||
|
||||
// Attempt to unwrap using Unwrap() or Cause()
|
||||
switch v := current.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
current = v.Unwrap()
|
||||
case interface{ Cause() error }:
|
||||
current = v.Cause()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With adds a key-value pair to an error's context, if it is an *Error.
|
||||
// Returns the original error unchanged if not an *Error; no-op for non-*Error types.
|
||||
func With(err error, key string, value interface{}) error {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.With(key, value)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WithStack converts any error to an *Error and captures a stack trace.
|
||||
// Returns nil if input is nil; adds stack to existing *Error or wraps non-*Error types.
|
||||
func WithStack(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.WithStack()
|
||||
}
|
||||
return New(err.Error()).WithStack().Wrap(err)
|
||||
}
|
||||
|
||||
// Wrap creates a new *Error that wraps another error with additional context.
|
||||
// Uses a copy of the provided wrapper *Error; returns nil if err is nil.
|
||||
func Wrap(err error, wrapper *Error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if wrapper == nil {
|
||||
wrapper = newError()
|
||||
}
|
||||
newErr := wrapper.Copy()
|
||||
newErr.cause = err
|
||||
return newErr
|
||||
}
|
||||
|
||||
// Wrapf creates a new formatted *Error that wraps another error.
|
||||
// Formats the message and sets the cause; returns nil if err is nil.
|
||||
func Wrapf(err error, format string, args ...interface{}) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
e := newError()
|
||||
e.msg = fmt.Sprintf(format, args...)
|
||||
e.cause = err
|
||||
return e
|
||||
}
|
||||
|
||||
// Err creates a new Error with the given message and wraps the provided error as its cause.
|
||||
func Err(msg string, err error) *Error {
|
||||
return New(msg).Wrap(err)
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// File: inspect.go
|
||||
// Updated to support both error and *Error with delegation for cleaner *Error handling
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
stderrs "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Inspect provides detailed examination of an error, handling both single errors and MultiError
|
||||
func Inspect(err error) {
|
||||
if err == nil {
|
||||
fmt.Println("No error occurred")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Error Inspection ===\n")
|
||||
fmt.Printf("Top-level error: %v\n", err)
|
||||
fmt.Printf("Top-level error type: %T\n", err)
|
||||
|
||||
// Handle *Error directly
|
||||
if e, ok := err.(*Error); ok {
|
||||
InspectError(e)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle MultiError
|
||||
if multi, ok := err.(*MultiError); ok {
|
||||
allErrors := multi.Errors()
|
||||
fmt.Printf("\nContains %d errors:\n", len(allErrors))
|
||||
for i, e := range allErrors {
|
||||
fmt.Printf("\n--- Error %d ---\n", i+1)
|
||||
inspectSingleError(e)
|
||||
}
|
||||
} else {
|
||||
// Inspect single error if not MultiError or *Error
|
||||
fmt.Println("\n--- Details ---")
|
||||
inspectSingleError(err)
|
||||
}
|
||||
|
||||
// Additional diagnostics
|
||||
fmt.Println("\n--- Diagnostics ---")
|
||||
if IsRetryable(err) {
|
||||
fmt.Println("- Error chain contains retryable errors")
|
||||
}
|
||||
if IsTimeout(err) {
|
||||
fmt.Println("- Error chain contains timeout errors")
|
||||
}
|
||||
if code := getErrorCode(err); code != 0 {
|
||||
fmt.Printf("- Highest priority error code: %d\n", code)
|
||||
}
|
||||
fmt.Printf("========================\n\n")
|
||||
}
|
||||
|
||||
// InspectError provides detailed inspection of a specific *Error instance
|
||||
func InspectError(err *Error) {
|
||||
if err == nil {
|
||||
fmt.Println("No error occurred")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Error Inspection (*Error) ===\n")
|
||||
fmt.Printf("Top-level error: %v\n", err)
|
||||
fmt.Printf("Top-level error type: %T\n", err)
|
||||
|
||||
fmt.Println("\n--- Details ---")
|
||||
inspectSingleError(err) // Delegate to handle unwrapping and details
|
||||
|
||||
// Additional diagnostics specific to *Error
|
||||
fmt.Println("\n--- Diagnostics ---")
|
||||
if IsRetryable(err) {
|
||||
fmt.Println("- Error is retryable")
|
||||
}
|
||||
if IsTimeout(err) {
|
||||
fmt.Println("- Error chain contains timeout errors")
|
||||
}
|
||||
if code := err.Code(); code != 0 {
|
||||
fmt.Printf("- Error code: %d\n", code)
|
||||
}
|
||||
fmt.Printf("========================\n\n")
|
||||
}
|
||||
|
||||
// inspectSingleError handles inspection of a single error (may be part of a chain)
|
||||
func inspectSingleError(err error) {
|
||||
if err == nil {
|
||||
fmt.Println(" (nil error)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
fmt.Printf(" Type: %T\n", err)
|
||||
|
||||
// Handle wrapped errors, including *Error type
|
||||
var currentErr error = err
|
||||
depth := 0
|
||||
for currentErr != nil {
|
||||
prefix := strings.Repeat(" ", depth+1)
|
||||
if depth > 0 {
|
||||
fmt.Printf("%sWrapped Cause (%T): %v\n", prefix, currentErr, currentErr)
|
||||
}
|
||||
|
||||
// Check if it's our specific *Error type
|
||||
if e, ok := currentErr.(*Error); ok {
|
||||
if name := e.Name(); name != "" {
|
||||
fmt.Printf("%sName: %s\n", prefix, name)
|
||||
}
|
||||
if cat := e.Category(); cat != "" {
|
||||
fmt.Printf("%sCategory: %s\n", prefix, cat)
|
||||
}
|
||||
if code := e.Code(); code != 0 {
|
||||
fmt.Printf("%sCode: %d\n", prefix, code)
|
||||
}
|
||||
if ctx := e.Context(); len(ctx) > 0 {
|
||||
fmt.Printf("%sContext:\n", prefix)
|
||||
for k, v := range ctx {
|
||||
fmt.Printf("%s %s: %v\n", prefix, k, v)
|
||||
}
|
||||
}
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
fmt.Printf("%sStack (Top 3):\n", prefix)
|
||||
limit := 3
|
||||
if len(stack) < limit {
|
||||
limit = len(stack)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
fmt.Printf("%s %s\n", prefix, stack[i])
|
||||
}
|
||||
if len(stack) > limit {
|
||||
fmt.Printf("%s ... (%d more frames)\n", prefix, len(stack)-limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap using standard errors.Unwrap and handle *Error Unwrap
|
||||
var nextErr error
|
||||
// Prioritize *Error's Unwrap if available AND it returns non-nil
|
||||
if e, ok := currentErr.(*Error); ok {
|
||||
unwrapped := e.Unwrap()
|
||||
if unwrapped != nil {
|
||||
nextErr = unwrapped
|
||||
} else {
|
||||
// If *Error.Unwrap returns nil, fall back to standard unwrap
|
||||
// This handles cases where *Error might wrap a non-standard error
|
||||
// or where its internal cause is deliberately nil.
|
||||
nextErr = stderrs.Unwrap(currentErr)
|
||||
}
|
||||
} else {
|
||||
nextErr = stderrs.Unwrap(currentErr) // Fall back to standard unwrap for non-*Error types
|
||||
}
|
||||
|
||||
// Prevent infinite loops if Unwrap returns the same error, or stop if no more unwrapping
|
||||
if nextErr == currentErr || nextErr == nil {
|
||||
break
|
||||
}
|
||||
currentErr = nextErr
|
||||
depth++
|
||||
if depth > 10 { // Safety break for very deep or potentially cyclic chains
|
||||
fmt.Printf("%s... (chain too deep or potential cycle)\n", strings.Repeat(" ", depth+1))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getErrorCode traverses the error chain to find the highest priority code.
|
||||
// It uses errors.As to find the first *Error in the chain.
|
||||
func getErrorCode(err error) int {
|
||||
var code int = 0 // Default code
|
||||
var target *Error
|
||||
if As(err, &target) { // Use the package's As helper
|
||||
if target != nil { // Add nil check for safety
|
||||
code = target.Code()
|
||||
}
|
||||
}
|
||||
// If the top-level error is *Error and has a code, it might take precedence.
|
||||
// This depends on desired logic. Let's keep it simple for now: first code found by As.
|
||||
if code == 0 { // Only check top-level if As didn't find one with a code
|
||||
if e, ok := err.(*Error); ok {
|
||||
code = e.Code()
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// handleError demonstrates using Inspect with additional handling logic
|
||||
func handleError(err error) {
|
||||
fmt.Println("\n=== Processing Failure ===")
|
||||
Inspect(err) // Use the primary Inspect function
|
||||
|
||||
// Additional handling based on inspection
|
||||
code := getErrorCode(err) // Use the helper
|
||||
|
||||
switch {
|
||||
case IsTimeout(err):
|
||||
fmt.Println("\nAction: Check connectivity or increase timeout")
|
||||
case code == 402: // Check code obtained via helper
|
||||
fmt.Println("\nAction: Payment processing failed - notify billing")
|
||||
default:
|
||||
fmt.Println("\nAction: Generic failure handling")
|
||||
}
|
||||
}
|
||||
|
||||
// processOrder demonstrates Chain usage with Inspect
|
||||
func processOrder() error {
|
||||
validateInput := func() error { return nil }
|
||||
processPayment := func() error { return stderrs.New("credit card declined") }
|
||||
sendNotification := func() error { fmt.Println("Notification sent."); return nil }
|
||||
logOrder := func() error { fmt.Println("Order logged."); return nil }
|
||||
|
||||
chain := NewChain(ChainWithTimeout(2*time.Second)).
|
||||
Step(validateInput).Tag("validation").
|
||||
Step(processPayment).Tag("billing").Code(402).Retry(3, 100*time.Millisecond, WithRetryIf(IsRetryable)).
|
||||
Step(sendNotification).Optional().
|
||||
Step(logOrder)
|
||||
|
||||
err := chain.Run()
|
||||
if err != nil {
|
||||
handleError(err) // Call the unified error handler
|
||||
return err // Propagate the error if needed
|
||||
}
|
||||
fmt.Println("Order processed successfully!")
|
||||
return nil
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// MultiError represents a thread-safe collection of errors with enhanced features.
|
||||
// Supports limits, sampling, and custom formatting for error aggregation.
|
||||
type MultiError struct {
|
||||
errors []error
|
||||
mu sync.RWMutex
|
||||
|
||||
// Configuration fields
|
||||
limit int // Maximum number of errors to store (0 = unlimited)
|
||||
formatter ErrorFormatter // Custom formatting function for error string
|
||||
sampling bool // Whether sampling is enabled to limit error collection
|
||||
sampleRate uint32 // Sampling percentage (1-100) when sampling is enabled
|
||||
rand *rand.Rand // Random source for sampling (nil defaults to fastRand)
|
||||
}
|
||||
|
||||
// ErrorFormatter defines a function for custom error message formatting.
|
||||
// Takes a slice of errors and returns a single formatted string.
|
||||
type ErrorFormatter func([]error) string
|
||||
|
||||
// MultiErrorOption configures MultiError behavior during creation.
|
||||
type MultiErrorOption func(*MultiError)
|
||||
|
||||
// NewMultiError creates a new MultiError instance with optional configuration.
|
||||
// Initial capacity is set to 4; applies options in the order provided.
|
||||
func NewMultiError(opts ...MultiErrorOption) *MultiError {
|
||||
m := &MultiError{
|
||||
errors: make([]error, 0, 4),
|
||||
limit: 0, // Unlimited by default
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Add appends an error to the collection with optional sampling, limit checks, and duplicate prevention.
|
||||
// Ignores nil errors and duplicates based on string equality; thread-safe.
|
||||
func (m *MultiError) Add(errs ...error) {
|
||||
if len(errs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates by comparing error messages
|
||||
duplicate := false
|
||||
for _, e := range m.errors {
|
||||
if e.Error() == err.Error() {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply sampling if enabled and collection isn’t empty
|
||||
if m.sampling && len(m.errors) > 0 {
|
||||
var r uint32
|
||||
if m.rand != nil {
|
||||
r = uint32(m.rand.Int31n(100))
|
||||
} else {
|
||||
r = fastRand() % 100
|
||||
}
|
||||
if r > m.sampleRate { // Accept if random value is within sample rate
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Respect limit if set
|
||||
if m.limit > 0 && len(m.errors) >= m.limit {
|
||||
continue
|
||||
}
|
||||
|
||||
m.errors = append(m.errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Addf formats and adds a new error to the collection.
|
||||
func (m *MultiError) Addf(format string, args ...interface{}) {
|
||||
m.Add(Newf(format, args...))
|
||||
}
|
||||
|
||||
// Clear removes all errors from the collection.
|
||||
// Thread-safe; resets the slice while preserving capacity.
|
||||
func (m *MultiError) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errors = m.errors[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of errors in the collection.
|
||||
// Thread-safe.
|
||||
func (m *MultiError) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.errors)
|
||||
}
|
||||
|
||||
// Error returns a formatted string representation of the errors.
|
||||
// Returns empty string if no errors, single error message if one exists,
|
||||
// or a formatted list using custom formatter or default if multiple; thread-safe.
|
||||
func (m *MultiError) Error() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
switch len(m.errors) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return m.errors[0].Error()
|
||||
default:
|
||||
if m.formatter != nil {
|
||||
return m.formatter(m.errors)
|
||||
}
|
||||
return defaultFormat(m.errors)
|
||||
}
|
||||
}
|
||||
|
||||
// Errors returns a copy of the contained errors.
|
||||
// Thread-safe; returns nil if no errors exist.
|
||||
func (m *MultiError) Errors() []error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if len(m.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
errs := make([]error, len(m.errors))
|
||||
copy(errs, m.errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// Filter returns a new MultiError containing only errors that match the predicate.
|
||||
// Thread-safe; preserves original configuration including limit, formatter, and sampling.
|
||||
func (m *MultiError) Filter(fn func(error) bool) *MultiError {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var opts []MultiErrorOption
|
||||
opts = append(opts, WithLimit(m.limit))
|
||||
if m.formatter != nil {
|
||||
opts = append(opts, WithFormatter(m.formatter))
|
||||
}
|
||||
if m.sampling {
|
||||
opts = append(opts, WithSampling(m.sampleRate))
|
||||
}
|
||||
|
||||
filtered := NewMultiError(opts...)
|
||||
for _, err := range m.errors {
|
||||
if fn(err) {
|
||||
filtered.Add(err)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// First returns the first error in the collection, if any.
|
||||
// Thread-safe; returns nil if the collection is empty.
|
||||
func (m *MultiError) First() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.errors) > 0 {
|
||||
return m.errors[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Has reports whether the collection contains any errors.
|
||||
// Thread-safe.
|
||||
func (m *MultiError) Has() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.errors) > 0
|
||||
}
|
||||
|
||||
// Last returns the most recently added error in the collection, if any.
|
||||
// Thread-safe; returns nil if the collection is empty.
|
||||
func (m *MultiError) Last() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.errors) > 0 {
|
||||
return m.errors[len(m.errors)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge combines another MultiError's errors into this one.
|
||||
// Thread-safe; respects this instance’s limit and sampling settings; no-op if other is nil or empty.
|
||||
func (m *MultiError) Merge(other *MultiError) {
|
||||
if other == nil || !other.Has() {
|
||||
return
|
||||
}
|
||||
|
||||
other.mu.RLock()
|
||||
defer other.mu.RUnlock()
|
||||
|
||||
for _, err := range other.errors {
|
||||
m.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
// IsNull checks if the MultiError is empty or contains only null errors.
|
||||
// Returns true if empty or all errors are null (via IsNull() or empty message); thread-safe.
|
||||
func (m *MultiError) IsNull() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Fast path for empty MultiError
|
||||
if len(m.errors) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check each error for null status
|
||||
allNull := true
|
||||
for _, err := range m.errors {
|
||||
switch e := err.(type) {
|
||||
case interface{ IsNull() bool }:
|
||||
if !e.IsNull() {
|
||||
allNull = false
|
||||
break
|
||||
}
|
||||
case nil:
|
||||
continue
|
||||
default:
|
||||
if e.Error() != "" {
|
||||
allNull = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNull
|
||||
}
|
||||
|
||||
// Single returns nil if the collection is empty, the single error if only one exists,
|
||||
// or the MultiError itself if multiple errors are present.
|
||||
// Thread-safe; useful for unwrapping to a single error when possible.
|
||||
func (m *MultiError) Single() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
switch len(m.errors) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return m.errors[0]
|
||||
default:
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
// String implements the Stringer interface for a concise string representation.
|
||||
// Thread-safe; delegates to Error() for formatting.
|
||||
func (m *MultiError) String() string {
|
||||
return m.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns a copy of the contained errors for multi-error unwrapping.
|
||||
// Implements the errors.Unwrap interface; thread-safe; returns nil if empty.
|
||||
func (m *MultiError) Unwrap() []error {
|
||||
return m.Errors()
|
||||
}
|
||||
|
||||
// WithFormatter sets a custom error formatting function.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; overrides default formatting.
|
||||
func WithFormatter(f ErrorFormatter) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
m.formatter = f
|
||||
}
|
||||
}
|
||||
|
||||
// WithLimit sets the maximum number of errors to store.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; 0 means unlimited, negative values are ignored.
|
||||
func WithLimit(n int) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
if n < 0 {
|
||||
n = 0 // Ensure non-negative limit
|
||||
}
|
||||
m.limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// WithSampling enables error sampling with a specified rate (1-100).
|
||||
// Returns a MultiErrorOption for use with NewMultiError; caps rate at 100 for validity.
|
||||
func WithSampling(rate uint32) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
m.sampling = true
|
||||
m.sampleRate = rate
|
||||
}
|
||||
}
|
||||
|
||||
// WithRand sets a custom random source for sampling, useful for testing.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; defaults to fastRand if nil.
|
||||
func WithRand(r *rand.Rand) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
m.rand = r
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON serializes the MultiError to JSON, including all contained errors and configuration metadata.
|
||||
// Thread-safe; errors are serialized using their MarshalJSON method if available, otherwise as strings.
|
||||
func (m *MultiError) MarshalJSON() ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get buffer from pool for efficiency
|
||||
buf := jsonBufferPool.Get().(*bytes.Buffer)
|
||||
defer jsonBufferPool.Put(buf)
|
||||
buf.Reset()
|
||||
|
||||
// Create encoder
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
// Define JSON structure
|
||||
type jsonError struct {
|
||||
Error interface{} `json:"error"` // Holds either JSON-marshaled error or string
|
||||
}
|
||||
|
||||
je := struct {
|
||||
Count int `json:"count"` // Number of errors
|
||||
Limit int `json:"limit,omitempty"` // Maximum error limit (omitted if 0)
|
||||
Sampling bool `json:"sampling,omitempty"` // Whether sampling is enabled
|
||||
SampleRate uint32 `json:"sample_rate,omitempty"` // Sampling rate (1-100, omitted if not sampling)
|
||||
Errors []jsonError `json:"errors"` // List of errors
|
||||
}{
|
||||
Count: len(m.errors),
|
||||
Limit: m.limit,
|
||||
Sampling: m.sampling,
|
||||
SampleRate: m.sampleRate,
|
||||
}
|
||||
|
||||
// Serialize each error
|
||||
je.Errors = make([]jsonError, len(m.errors))
|
||||
for i, err := range m.errors {
|
||||
if err == nil {
|
||||
je.Errors[i] = jsonError{Error: nil}
|
||||
continue
|
||||
}
|
||||
// Check if the error implements json.Marshaler
|
||||
if marshaler, ok := err.(json.Marshaler); ok {
|
||||
marshaled, err := marshaler.MarshalJSON()
|
||||
if err != nil {
|
||||
// Fallback to string if marshaling fails
|
||||
je.Errors[i] = jsonError{Error: err.Error()}
|
||||
} else {
|
||||
var raw json.RawMessage = marshaled
|
||||
je.Errors[i] = jsonError{Error: raw}
|
||||
}
|
||||
} else {
|
||||
// Use error string for non-marshaler errors
|
||||
je.Errors[i] = jsonError{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Encode JSON
|
||||
if err := enc.Encode(je); err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal MultiError: %v", err)
|
||||
}
|
||||
|
||||
// Remove trailing newline
|
||||
result := buf.Bytes()
|
||||
if len(result) > 0 && result[len(result)-1] == '\n' {
|
||||
result = result[:len(result)-1]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// defaultFormat provides the default formatting for multiple errors.
|
||||
// Returns a semicolon-separated list prefixed with the error count (e.g., "errors(3): err1; err2; err3").
|
||||
func defaultFormat(errs []error) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("errors(%d): ", len(errs)))
|
||||
for i, err := range errs {
|
||||
if i > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(err.Error())
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// fastRand generates a quick pseudo-random number for sampling.
|
||||
// Uses a simple xorshift algorithm based on the current time; not cryptographically secure.
|
||||
var fastRandState uint32 = 1 // Must be non-zero
|
||||
|
||||
func fastRand() uint32 {
|
||||
for {
|
||||
// Atomically load the current state
|
||||
old := atomic.LoadUint32(&fastRandState)
|
||||
// Xorshift computation
|
||||
x := old
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
// Attempt to store the new state atomically
|
||||
if atomic.CompareAndSwapUint32(&fastRandState, old, x) {
|
||||
return x
|
||||
}
|
||||
// Otherwise retry
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// pool.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrorPool is a high-performance, thread-safe pool for reusing *Error instances.
|
||||
// Reduces allocation overhead by recycling errors; tracks hit/miss statistics.
|
||||
type ErrorPool struct {
|
||||
pool sync.Pool // Underlying pool for storing *Error instances
|
||||
poolStats struct { // Embedded struct for pool usage statistics
|
||||
hits atomic.Int64 // Number of times an error was reused from the pool
|
||||
misses atomic.Int64 // Number of times a new error was created due to pool miss
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorPool creates a new ErrorPool instance.
|
||||
// Initializes the pool with a New function that returns a fresh *Error with default smallContext.
|
||||
func NewErrorPool() *ErrorPool {
|
||||
return &ErrorPool{
|
||||
pool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves an *Error from the pool or creates a new one if pooling is disabled or pool is empty.
|
||||
// Resets are handled by Put; thread-safe; updates hit/miss stats when pooling is enabled.
|
||||
func (ep *ErrorPool) Get() *Error {
|
||||
if currentConfig.disablePooling {
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
}
|
||||
|
||||
e := ep.pool.Get().(*Error)
|
||||
if e == nil { // Pool returned nil (unlikely due to New func, but handled for safety)
|
||||
ep.poolStats.misses.Add(1)
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
}
|
||||
ep.poolStats.hits.Add(1)
|
||||
return e
|
||||
}
|
||||
|
||||
// Put returns an *Error to the pool after resetting it.
|
||||
// Ignores nil errors or if pooling is disabled; preserves stack capacity; thread-safe.
|
||||
func (ep *ErrorPool) Put(e *Error) {
|
||||
if e == nil || currentConfig.disablePooling {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset the error to a clean state, preserving capacity
|
||||
e.Reset()
|
||||
|
||||
// Reset stack length while keeping capacity for reuse
|
||||
if e.stack != nil {
|
||||
e.stack = e.stack[:0]
|
||||
}
|
||||
|
||||
ep.pool.Put(e)
|
||||
}
|
||||
|
||||
// Stats returns the current pool statistics as hits and misses.
|
||||
// Thread-safe; uses atomic loads to ensure accurate counts.
|
||||
func (ep *ErrorPool) Stats() (hits, misses int64) {
|
||||
return ep.poolStats.hits.Load(), ep.poolStats.misses.Load()
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
//go:build go1.24
|
||||
// +build go1.24
|
||||
|
||||
package errors
|
||||
|
||||
import "runtime"
|
||||
|
||||
// setupCleanup configures a cleanup function for an *Error to auto-return it to the pool.
|
||||
// Only active for Go 1.24+; uses runtime.AddCleanup when autoFree is set and pooling is enabled.
|
||||
func (ep *ErrorPool) setupCleanup(e *Error) {
|
||||
if currentConfig.autoFree {
|
||||
runtime.AddCleanup(e, func(_ *struct{}) {
|
||||
if !currentConfig.disablePooling {
|
||||
ep.Put(e) // Return to pool when cleaned up
|
||||
}
|
||||
}, nil) // No additional context needed
|
||||
}
|
||||
}
|
||||
|
||||
// clearCleanup is a no-op for Go 1.24 and above.
|
||||
// Cleanup is managed by runtime.AddCleanup; no explicit removal is required.
|
||||
func (ep *ErrorPool) clearCleanup(e *Error) {
|
||||
// No-op for Go 1.24+
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
//go:build !go1.24
|
||||
// +build !go1.24
|
||||
|
||||
package errors
|
||||
|
||||
import "runtime"
|
||||
|
||||
// setupCleanup configures a finalizer for an *Error to auto-return it to the pool.
|
||||
// Only active for Go versions < 1.24; enables automatic cleanup when autoFree is set and pooling is enabled.
|
||||
func (ep *ErrorPool) setupCleanup(e *Error) {
|
||||
if currentConfig.autoFree {
|
||||
runtime.SetFinalizer(e, func(e *Error) {
|
||||
if !currentConfig.disablePooling {
|
||||
ep.Put(e) // Return to pool when garbage collected
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clearCleanup removes any finalizer set on an *Error.
|
||||
// Only active for Go versions < 1.24; ensures no cleanup action occurs on garbage collection.
|
||||
func (ep *ErrorPool) clearCleanup(e *Error) {
|
||||
runtime.SetFinalizer(e, nil) // Disable finalizer
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
// Package errors provides utilities for error handling, including a flexible retry mechanism.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackoffStrategy defines the interface for calculating retry delays.
|
||||
type BackoffStrategy interface {
|
||||
// Backoff returns the delay for a given attempt based on the base delay.
|
||||
Backoff(attempt int, baseDelay time.Duration) time.Duration
|
||||
}
|
||||
|
||||
// ConstantBackoff provides a fixed delay for each retry attempt.
|
||||
type ConstantBackoff struct{}
|
||||
|
||||
// Backoff returns the base delay regardless of the attempt number.
|
||||
// Implements BackoffStrategy with a constant delay.
|
||||
func (c ConstantBackoff) Backoff(_ int, baseDelay time.Duration) time.Duration {
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// ExponentialBackoff provides an exponentially increasing delay for retry attempts.
|
||||
type ExponentialBackoff struct{}
|
||||
|
||||
// Backoff returns a delay that doubles with each attempt, starting from the base delay.
|
||||
// Uses bit shifting for efficient exponential growth (e.g., baseDelay * 2^(attempt-1)).
|
||||
func (e ExponentialBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
|
||||
if attempt <= 1 {
|
||||
return baseDelay
|
||||
}
|
||||
return baseDelay * time.Duration(1<<uint(attempt-1))
|
||||
}
|
||||
|
||||
// LinearBackoff provides a linearly increasing delay for retry attempts.
|
||||
type LinearBackoff struct{}
|
||||
|
||||
// Backoff returns a delay that increases linearly with each attempt (e.g., baseDelay * attempt).
|
||||
// Implements BackoffStrategy with linear progression.
|
||||
func (l LinearBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
|
||||
return baseDelay * time.Duration(attempt)
|
||||
}
|
||||
|
||||
// RetryOption configures a Retry instance.
|
||||
// Defines a function type for setting retry parameters.
|
||||
type RetryOption func(*Retry)
|
||||
|
||||
// Retry represents a retryable operation with configurable backoff and retry logic.
|
||||
// Supports multiple attempts, delay strategies, jitter, and context-aware cancellation.
|
||||
type Retry struct {
|
||||
maxAttempts int // Maximum number of attempts (including initial try)
|
||||
delay time.Duration // Base delay for backoff calculations
|
||||
maxDelay time.Duration // Maximum delay cap to prevent excessive waits
|
||||
retryIf func(error) bool // Condition to determine if retry should occur
|
||||
onRetry func(int, error) // Callback executed after each failed attempt
|
||||
backoff BackoffStrategy // Strategy for calculating retry delays
|
||||
jitter bool // Whether to add random jitter to delays
|
||||
ctx context.Context // Context for cancellation and deadlines
|
||||
}
|
||||
|
||||
// NewRetry creates a new Retry instance with the given options.
|
||||
// Defaults: 3 attempts, 100ms base delay, 10s max delay, exponential backoff with jitter,
|
||||
// and retrying on IsRetryable errors; ensures retryIf is never nil.
|
||||
func NewRetry(options ...RetryOption) *Retry {
|
||||
r := &Retry{
|
||||
maxAttempts: 3,
|
||||
delay: 100 * time.Millisecond,
|
||||
maxDelay: 10 * time.Second,
|
||||
retryIf: func(err error) bool { return IsRetryable(err) },
|
||||
onRetry: nil,
|
||||
backoff: ExponentialBackoff{},
|
||||
jitter: true,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(r)
|
||||
}
|
||||
// Ensure retryIf is never nil, falling back to IsRetryable
|
||||
if r.retryIf == nil {
|
||||
r.retryIf = func(err error) bool { return IsRetryable(err) }
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// addJitter adds ±25% jitter to avoid thundering herd problems.
|
||||
// Returns a duration adjusted by a random value between -25% and +25% of the input; not thread-safe.
|
||||
func addJitter(d time.Duration) time.Duration {
|
||||
jitter := time.Duration(rand.Int63n(int64(d/2))) - (d / 4)
|
||||
return d + jitter
|
||||
}
|
||||
|
||||
// Attempts returns the configured maximum number of retry attempts.
|
||||
// Includes the initial attempt in the count.
|
||||
func (r *Retry) Attempts() int {
|
||||
return r.maxAttempts
|
||||
}
|
||||
|
||||
// Execute runs the provided function with the configured retry logic.
|
||||
// Returns nil on success or the last error if all attempts fail; respects context cancellation.
|
||||
func (r *Retry) Execute(fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
// Check context before each attempt
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if we should retry
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
// Don't delay after last attempt
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate delay with backoff
|
||||
delay := r.backoff.Backoff(attempt, r.delay)
|
||||
if r.maxDelay > 0 && delay > r.maxDelay {
|
||||
delay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
delay = addJitter(delay)
|
||||
}
|
||||
|
||||
// Wait with context
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// ExecuteContext runs the provided function with retry logic, respecting context cancellation.
|
||||
// Returns nil on success or the last error if all attempts fail or context is cancelled.
|
||||
func (r *Retry) ExecuteContext(ctx context.Context, fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
// If the retry instance already has a context, use it. Otherwise, use the provided one.
|
||||
// If both are provided, maybe create a derived context? For now, prioritize the one from WithContext.
|
||||
execCtx := r.ctx
|
||||
if execCtx == context.Background() && ctx != nil { // Use provided ctx if retry ctx is default and provided one isn't nil
|
||||
execCtx = ctx
|
||||
} else if ctx == nil { // Ensure we always have a non-nil context
|
||||
execCtx = context.Background()
|
||||
}
|
||||
// Note: This logic might need refinement depending on how contexts should interact.
|
||||
// A safer approach might be: if r.ctx != background, use it. Else use provided ctx.
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
// Check context before executing the function
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
return execCtx.Err() // Return context error immediately
|
||||
default:
|
||||
// Context is okay, proceed
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
|
||||
// Check if retry is applicable based on the error
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return err // Not retryable, return the error
|
||||
}
|
||||
|
||||
lastErr = err // Store the last encountered error
|
||||
|
||||
// Execute the OnRetry callback if configured
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
// Exit loop if this was the last attempt
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// --- Calculate and apply delay ---
|
||||
currentDelay := r.backoff.Backoff(attempt, r.delay)
|
||||
if r.maxDelay > 0 && currentDelay > r.maxDelay { // Check maxDelay > 0 before capping
|
||||
currentDelay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
currentDelay = addJitter(currentDelay)
|
||||
}
|
||||
if currentDelay < 0 { // Ensure delay isn't negative after jitter
|
||||
currentDelay = 0
|
||||
}
|
||||
// --- Wait for the delay or context cancellation ---
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
// If context is cancelled during the wait, return the context error
|
||||
// Often more informative than returning the last application error.
|
||||
return execCtx.Err()
|
||||
case <-time.After(currentDelay):
|
||||
// Wait finished, continue to the next attempt
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed, return the last error encountered
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Transform creates a new Retry instance with modified configuration.
|
||||
// Copies all settings from the original Retry and applies the given options.
|
||||
func (r *Retry) Transform(opts ...RetryOption) *Retry {
|
||||
newRetry := &Retry{
|
||||
maxAttempts: r.maxAttempts,
|
||||
delay: r.delay,
|
||||
maxDelay: r.maxDelay,
|
||||
retryIf: r.retryIf,
|
||||
onRetry: r.onRetry,
|
||||
backoff: r.backoff,
|
||||
jitter: r.jitter,
|
||||
ctx: r.ctx,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(newRetry)
|
||||
}
|
||||
return newRetry
|
||||
}
|
||||
|
||||
// WithBackoff sets the backoff strategy using the BackoffStrategy interface.
|
||||
// Returns a RetryOption; no-op if strategy is nil, retaining the existing strategy.
|
||||
func WithBackoff(strategy BackoffStrategy) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if strategy != nil {
|
||||
r.backoff = strategy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context for cancellation and deadlines.
|
||||
// Returns a RetryOption; retains context.Background if ctx is nil.
|
||||
func WithContext(ctx context.Context) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if ctx != nil {
|
||||
r.ctx = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDelay sets the initial delay between retries.
|
||||
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
|
||||
func WithDelay(delay time.Duration) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
r.delay = delay
|
||||
}
|
||||
}
|
||||
|
||||
// WithJitter enables or disables jitter in the backoff delay.
|
||||
// Returns a RetryOption; toggles random delay variation.
|
||||
func WithJitter(jitter bool) RetryOption {
|
||||
return func(r *Retry) {
|
||||
r.jitter = jitter
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxAttempts sets the maximum number of retry attempts.
|
||||
// Returns a RetryOption; ensures at least 1 attempt by adjusting lower values.
|
||||
func WithMaxAttempts(maxAttempts int) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
r.maxAttempts = maxAttempts
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxDelay sets the maximum delay between retries.
|
||||
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
|
||||
func WithMaxDelay(maxDelay time.Duration) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if maxDelay < 0 {
|
||||
maxDelay = 0
|
||||
}
|
||||
r.maxDelay = maxDelay
|
||||
}
|
||||
}
|
||||
|
||||
// WithOnRetry sets a callback to execute after each failed attempt.
|
||||
// Returns a RetryOption; callback receives attempt number and error.
|
||||
func WithOnRetry(onRetry func(attempt int, err error)) RetryOption {
|
||||
return func(r *Retry) {
|
||||
r.onRetry = onRetry
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryIf sets the condition under which to retry.
|
||||
// Returns a RetryOption; retains IsRetryable default if retryIf is nil.
|
||||
func WithRetryIf(retryIf func(error) bool) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if retryIf != nil {
|
||||
r.retryIf = retryIf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteReply runs the provided function with retry logic and returns its result.
|
||||
// Returns the result and nil on success, or zero value and last error on failure; generic type T.
|
||||
func ExecuteReply[T any](r *Retry, fn func() (T, error)) (T, error) {
|
||||
var lastErr error
|
||||
var zero T
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Check if retry is applicable; return immediately if not retryable
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate delay with backoff, cap at maxDelay, and apply jitter if enabled
|
||||
currentDelay := r.backoff.Backoff(attempt, r.delay)
|
||||
if currentDelay > r.maxDelay {
|
||||
currentDelay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
currentDelay = addJitter(currentDelay)
|
||||
}
|
||||
|
||||
// Wait with respect to context cancellation or timeout
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return zero, r.ctx.Err()
|
||||
case <-time.After(currentDelay):
|
||||
}
|
||||
}
|
||||
return zero, lastErr
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// Package errors provides utility functions for error handling, including stack
|
||||
// trace capture and function name extraction.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// captureStack captures a stack trace with the configured depth.
|
||||
// Skip=0 captures the current call site; skips captureStack and its caller (+2 frames); thread-safe via stackPool.
|
||||
func captureStack(skip int) []uintptr {
|
||||
buf := stackPool.Get().([]uintptr)
|
||||
buf = buf[:cap(buf)]
|
||||
|
||||
// +2 to skip captureStack and the immediate caller
|
||||
n := runtime.Callers(skip+2, buf)
|
||||
if n == 0 {
|
||||
stackPool.Put(buf)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new slice to return, avoiding direct use of pooled memory
|
||||
stack := make([]uintptr, n)
|
||||
copy(stack, buf[:n])
|
||||
stackPool.Put(buf)
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
// min returns the smaller of two integers.
|
||||
// Simple helper for limiting stack trace size or other comparisons.
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// clearMap removes all entries from a map.
|
||||
// Helper function to reset map contents without reallocating.
|
||||
func clearMap(m map[string]interface{}) {
|
||||
for k := range m {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
|
||||
// sqlNull detects if a value represents a SQL NULL type.
|
||||
// Returns true for nil or invalid sql.Null* types (e.g., NullString, NullInt64); false otherwise.
|
||||
func sqlNull(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case sql.NullString:
|
||||
return !val.Valid
|
||||
case sql.NullTime:
|
||||
return !val.Valid
|
||||
case sql.NullInt64:
|
||||
return !val.Valid
|
||||
case sql.NullBool:
|
||||
return !val.Valid
|
||||
case sql.NullFloat64:
|
||||
return !val.Valid
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// getFuncName extracts the function name from an interface, typically a function or method.
|
||||
// Returns "unknown" if the input is nil or invalid; trims leading dots from runtime name.
|
||||
func getFuncName(fn interface{}) string {
|
||||
if fn == nil {
|
||||
return "unknown"
|
||||
}
|
||||
fullName := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name()
|
||||
return strings.TrimPrefix(fullName, ".")
|
||||
}
|
||||
|
||||
// isInternalFrame determines if a stack frame is considered "internal".
|
||||
// Returns true for frames from runtime, reflect, or this package’s subdirectories if FilterInternal is true.
|
||||
func isInternalFrame(frame runtime.Frame) bool {
|
||||
if strings.HasPrefix(frame.Function, "runtime.") || strings.HasPrefix(frame.Function, "reflect.") {
|
||||
return true
|
||||
}
|
||||
|
||||
suffixes := []string{
|
||||
"errors",
|
||||
"utils",
|
||||
"helper",
|
||||
"retry",
|
||||
"multi",
|
||||
}
|
||||
|
||||
file := frame.File
|
||||
for _, v := range suffixes {
|
||||
if strings.Contains(file, fmt.Sprintf("github.com/olekukonko/errors/%s", v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatError returns a formatted string representation of an error.
|
||||
// Includes message, name, context, stack trace, and cause for *Error types; just message for others; "<nil>" if nil.
|
||||
func FormatError(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
var sb strings.Builder
|
||||
if e, ok := err.(*Error); ok {
|
||||
sb.WriteString(fmt.Sprintf("Error: %s\n", e.Error()))
|
||||
if e.name != "" {
|
||||
sb.WriteString(fmt.Sprintf("Name: %s\n", e.name))
|
||||
}
|
||||
if ctx := e.Context(); len(ctx) > 0 {
|
||||
sb.WriteString("Context:\n")
|
||||
for k, v := range ctx {
|
||||
sb.WriteString(fmt.Sprintf("\t%s: %v\n", k, v))
|
||||
}
|
||||
}
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
sb.WriteString("Stack Trace:\n")
|
||||
for _, frame := range stack {
|
||||
sb.WriteString(fmt.Sprintf("\t%s\n", frame))
|
||||
}
|
||||
}
|
||||
if e.cause != nil {
|
||||
sb.WriteString(fmt.Sprintf("Caused by: %s\n", FormatError(e.cause)))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Error: %s\n", err.Error()))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Caller returns the file, line, and function name of the caller at the specified skip level.
|
||||
// Skip=0 returns the caller of this function, 1 returns its caller, etc.; returns "unknown" if no caller found.
|
||||
func Caller(skip int) (file string, line int, function string) {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
var pcs [1]uintptr
|
||||
n := runtime.Callers(skip+2, pcs[:]) // +2 skips Caller and its immediate caller
|
||||
if n == 0 {
|
||||
return "", 0, "unknown"
|
||||
}
|
||||
frame, _ := runtime.CallersFrames(pcs[:n]).Next()
|
||||
return frame.File, frame.Line, frame.Function
|
||||
}
|
||||
Reference in New Issue
Block a user