From d1ea7874febadad6f94ce4d3816ff6649b7cba78 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 11 Oct 2025 22:01:18 +0400 Subject: [PATCH] feat: add 'HandleErrorOrDefault' with fallback handler --- README.md | 23 ++++ Taskfile.yaml | 2 +- errors/handler.go | 39 ++++++ errors/handler_test.go | 282 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c055aa8..7d0a745 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,29 @@ result := errors.HandleErrorOrDie(err, ) // Panics if err doesn't match any handler ``` +**HandleErrorOrDefault** - Handle errors with a default fallback +```go +result := errors.HandleErrorOrDefault(err, + func(e error) error { + // Default handler for unmatched errors + return fmt.Errorf("unexpected error: %w", e) + }, + errors.OnSentinelError(context.Canceled, func(e error) error { + return fmt.Errorf("operation canceled") + }), + errors.OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation failed: %w", e) + }), +) + +// Pass nil to suppress unmatched errors +result := errors.HandleErrorOrDefault(err, nil, + errors.OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handled") + }), +) // Returns nil for unmatched errors +``` + **OnSentinelError** - Create matcher for sentinel errors (like `io.EOF`) ```go matcher := errors.OnSentinelError(io.EOF, func(e error) error { diff --git a/Taskfile.yaml b/Taskfile.yaml index a358183..a70932e 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -14,7 +14,7 @@ tasks: tidy: go mod tidy {{.CLI_ARGS}} - test: go test ./... {{.CLI_ARGS}} + test: go test -race ./... {{.CLI_ARGS}} fmt: desc: Run linters diff --git a/errors/handler.go b/errors/handler.go index 640b939..7b4da48 100644 --- a/errors/handler.go +++ b/errors/handler.go @@ -81,6 +81,45 @@ func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error { return err } +// HandleErrorOrDefault processes an error against a list of matchers and executes the appropriate handler. +// If a matching handler is found, it returns the handler's result. +// If no matcher matches the error, it executes the default handler (dft) and returns its result. +// If dft is nil, unmatched errors return nil (effectively suppressing the error). +// This function is useful when you want to handle specific error cases explicitly +// while providing a fallback handler for all other errors. +// +// Example: +// +// result := HandleErrorOrDefault(err, +// func(e error) error { +// // Default handler for unmatched errors +// return fmt.Errorf("unexpected error: %w", e) +// }, +// OnSentinelError(context.Canceled, func(e error) error { +// return fmt.Errorf("operation canceled") +// }), +// OnCustomError(func(e *ValidationError) error { +// return fmt.Errorf("validation failed: %w", e) +// }), +// ) +// +// // Suppress unmatched errors by passing nil as default handler +// result := HandleErrorOrDefault(err, nil, +// OnSentinelError(io.EOF, func(e error) error { +// return errors.New("EOF handled") +// }), +// ) // Returns nil for unmatched errors +func HandleErrorOrDefault(err error, dft ErrorHandler, matchers ...ErrorMatcher) error { + ok, err := HandleError(err, matchers...) + if !ok { + if dft == nil { + return nil + } + return dft(err) + } + return err +} + // OnSentinelError creates an ErrorMatcher for sentinel errors. // Sentinel errors are predefined error values that are compared using errors.Is. // diff --git a/errors/handler_test.go b/errors/handler_test.go index d9e212f..8204382 100644 --- a/errors/handler_test.go +++ b/errors/handler_test.go @@ -251,6 +251,288 @@ func TestHandleErrorOrDie(t *testing.T) { }) } +func TestHandleErrorOrDefault(t *testing.T) { + t.Run("HandleErrorOrDefault with nil error", func(t *testing.T) { + defaultCalled := false + result := HandleErrorOrDefault( + nil, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + ) + require.NoError(t, result) + assert.False(t, defaultCalled) + }) + + t.Run("HandleErrorOrDefault with matched error", func(t *testing.T) { + err := io.EOF + defaultCalled := false + result := HandleErrorOrDefault( + err, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("handled EOF") + }), + ) + require.EqualError(t, result, "handled EOF") + assert.False(t, defaultCalled) + }) + + t.Run("HandleErrorOrDefault with unmatched error calls default", func(t *testing.T) { + err := errors.New("unmatched error") + defaultCalled := false + var capturedErr error + + result := HandleErrorOrDefault( + err, + func(e error) error { + defaultCalled = true + capturedErr = e + return fmt.Errorf("default: %w", e) + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + ) + + assert.True(t, defaultCalled) + assert.Equal(t, err, capturedErr) + assert.EqualError(t, result, "default: unmatched error") + }) + + t.Run("HandleErrorOrDefault with custom error match", func(t *testing.T) { + customErr := &CustomError{Code: 404, Message: "not found"} + defaultCalled := false + + result := HandleErrorOrDefault( + customErr, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("custom handler: code=%d", e.Code) + }), + ) + + assert.False(t, defaultCalled) + assert.EqualError(t, result, "custom handler: code=404") + }) + + t.Run("HandleErrorOrDefault with custom error no match", func(t *testing.T) { + customErr := &CustomError{Code: 500, Message: "server error"} + defaultCalled := false + + result := HandleErrorOrDefault( + customErr, + func(e error) error { + defaultCalled = true + return fmt.Errorf("default for custom: %v", e) + }, + OnCustomError(func(e *ValidationError) error { + return errors.New("validation handler") + }), + ) + + assert.True(t, defaultCalled) + assert.EqualError(t, result, "default for custom: custom error 500: server error") + }) + + t.Run("HandleErrorOrDefault with multiple matchers", func(t *testing.T) { + validationErr := &ValidationError{Field: "email", Value: "invalid"} + defaultCalled := false + + result := HandleErrorOrDefault( + validationErr, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + OnCustomError(func(e *CustomError) error { + return errors.New("custom handler") + }), + OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation handler: field=%s", e.Field) + }), + ) + + assert.False(t, defaultCalled) + assert.EqualError(t, result, "validation handler: field=email") + }) + + t.Run("HandleErrorOrDefault default returns nil", func(t *testing.T) { + err := errors.New("some error") + defaultCalled := false + + result := HandleErrorOrDefault( + err, + func(e error) error { + defaultCalled = true + return nil // Default handler suppresses error + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + ) + + assert.True(t, defaultCalled) + assert.NoError(t, result) + }) + + t.Run("HandleErrorOrDefault with wrapped error", func(t *testing.T) { + wrappedErr := fmt.Errorf("wrapped: %w", io.EOF) + defaultCalled := false + + result := HandleErrorOrDefault( + wrappedErr, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("handled wrapped EOF") + }), + ) + + assert.False(t, defaultCalled) + assert.EqualError(t, result, "handled wrapped EOF") + }) + + t.Run("HandleErrorOrDefault with context errors", func(t *testing.T) { + // Test matched context.Canceled + defaultCalled := false + result1 := HandleErrorOrDefault( + context.Canceled, + func(e error) error { + defaultCalled = true + return errors.New("default handler") + }, + OnSentinelError(context.Canceled, func(e error) error { + return errors.New("operation canceled") + }), + ) + assert.False(t, defaultCalled) + require.EqualError(t, result1, "operation canceled") + + // Test unmatched context.DeadlineExceeded + defaultCalled = false + result2 := HandleErrorOrDefault( + context.DeadlineExceeded, + func(e error) error { + defaultCalled = true + return errors.New("default: deadline exceeded") + }, + OnSentinelError(context.Canceled, func(e error) error { + return errors.New("operation canceled") + }), + ) + assert.True(t, defaultCalled) + assert.EqualError(t, result2, "default: deadline exceeded") + }) + + t.Run("HandleErrorOrDefault preserves error in default handler", func(t *testing.T) { + originalErr := &CustomError{Code: 403, Message: "forbidden"} + var capturedErr error + + result := HandleErrorOrDefault( + originalErr, + func(e error) error { + capturedErr = e + return fmt.Errorf("default handled: %w", e) + }, + OnCustomError(func(e *ValidationError) error { + return errors.New("validation handler") + }), + ) + + assert.Equal(t, originalErr, capturedErr) + assert.EqualError(t, result, "default handled: custom error 403: forbidden") + }) + + t.Run("HandleErrorOrDefault default handler can access error details", func(t *testing.T) { + customErr := &CustomError{Code: 500, Message: "internal error"} + wrappedErr := fmt.Errorf("operation failed: %w", customErr) + + result := HandleErrorOrDefault( + wrappedErr, + func(e error) error { + // Default handler can still use errors.As to extract details + var ce *CustomError + if errors.As(e, &ce) { + return fmt.Errorf("default: found code %d", ce.Code) + } + return errors.New("default: unknown error") + }, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + ) + + assert.EqualError(t, result, "default: found code 500") + }) + + t.Run("HandleErrorOrDefault with nil default handler and no match", func(t *testing.T) { + err := errors.New("unmatched error") + + result := HandleErrorOrDefault( + err, + nil, // nil default handler + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + ) + + assert.NoError(t, result) // Should return nil for unmatched errors + }) + + t.Run("HandleErrorOrDefault with nil default handler and match", func(t *testing.T) { + err := io.EOF + + result := HandleErrorOrDefault( + err, + nil, // nil default handler (should not be called) + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handled") + }), + ) + + assert.EqualError(t, result, "EOF handled") // Should use the matcher, not default + }) + + t.Run("HandleErrorOrDefault with nil default handler and nil error", func(t *testing.T) { + result := HandleErrorOrDefault( + nil, + nil, // nil default handler + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + ) + + assert.NoError(t, result) // nil error should return nil + }) + + t.Run("HandleErrorOrDefault with nil default handler suppresses custom errors", func(t *testing.T) { + customErr := &CustomError{Code: 404, Message: "not found"} + + result := HandleErrorOrDefault( + customErr, + nil, // nil default handler suppresses unmatched errors + OnCustomError(func(e *ValidationError) error { + return errors.New("validation handler") + }), + ) + + assert.NoError(t, result) // Should return nil since no matcher matches + }) +} + func TestOnSentinelError(t *testing.T) { t.Run("OnSentinelError creates proper matcher", func(t *testing.T) { handler := func(e error) error { return e }