My code snippet represents a way of handling chained errors. I need a code-review to understand whether my way is idiomatic and relevant for the 2021?
My personal requirements:
- I want each package of my project to have an
errors.go
file with defined sentinel-style errors. This, in my opinion, hugely improves readability/maintainability. - I want to utilize new
errors.Is/As
functionality. - I want to hide implementation details of underlying packages. In my code snippet - I don't want the
web
package to know anything aboutrepository.NotFoundError
andrepository.DatabaseError
. But I do want my top-level web error to have full chain of underlying error strings (possibly, error contexts) for describing them in logs, HTTP responses, etc.
Here is my humble snippet (one may launch it with go test):
package main
import (
"errors"
"fmt"
"testing"
)
// the code below should be kept in some kind of common place
type CustomError struct {
Message string
Child error
}
func (cerr *CustomError) Error() string {
if cerr.Child != nil {
return fmt.Sprintf("%s: %s", cerr.Message, cerr.Child.Error())
}
return cerr.Message
}
func (cerr *CustomError) Unwrap() error {
return cerr.Child
}
func (cerr *CustomError) Wrap(child error) error {
cerr.Child = child
return cerr
}
func CustomErrorBuilder(message string, child error) *CustomError {
return &CustomError{Message: message, Child: child}
}
// the code below represents the 'repository' package
var (
NotFoundError = CustomErrorBuilder("NotFoundError", nil)
DatabaseError = CustomErrorBuilder("DatabaseError", nil)
)
func QueryUser(id int) (string, error) {
if id == 0 {
return "User 0", nil
}
if id == 1 {
return "User 1", nil
}
if id == 100 {
return "", DatabaseError
}
return "", NotFoundError
}
// the code below represents the 'core' package
var (
InfrastructureError = CustomErrorBuilder("InfrastructureError", nil)
BusinessLogicError = CustomErrorBuilder("BusinessLogicError", nil)
)
func UserHasName(id int, name string) (bool, error) {
userName, err := QueryUser(id)
if err != nil {
if errors.Is(err, NotFoundError) {
return false, BusinessLogicError.Wrap(NotFoundError)
}
if errors.Is(err, DatabaseError) {
return false, InfrastructureError.Wrap(DatabaseError)
}
}
if userName == name {
return true, nil
} else {
return false, nil
}
}
// the code below represents the 'web' package
func Handler(id int, name string) (int, string) {
result, err := UserHasName(id, name)
if err != nil {
if errors.Is(err, BusinessLogicError) {
return 404, fmt.Sprintf("NOT FOUND %v", err)
}
if errors.Is(err, InfrastructureError) {
return 500, fmt.Sprintf("INTERNAL SERVER ERROR %v", err)
}
}
return 200, fmt.Sprintf("OK %t", result)
}
// This test checks errors wrapping
func TestHandler(t *testing.T) {
testCases := []struct {
userId int
userName string
expectedStatus int
expectedBody string
}{
{userId: 0, userName: "User 0", expectedStatus: 200, expectedBody: "OK true"},
{userId: 1, userName: "User 0", expectedStatus: 200, expectedBody: "OK false"},
{userId: 2, userName: "", expectedStatus: 404, expectedBody: "NOT FOUND BusinessLogicError: NotFoundError"},
{userId: 100, userName: "", expectedStatus: 500, expectedBody: "INTERNAL SERVER ERROR InfrastructureError: DatabaseError"},
}
for i, tcase := range testCases {
t.Run(fmt.Sprintf("Case %v", i), func(t *testing.T) {
status, body := Handler(tcase.userId, tcase.userName)
if status != tcase.expectedStatus {
t.Fatalf("%v != %v", status, tcase.expectedStatus)
}
if body != tcase.expectedBody {
t.Fatalf("%s != %s", body, tcase.expectedBody)
}
})
}
}
```
1 Answer 1
A couple of things:
If you are not returning any rich data in your errors (beyond an extra error string), then a custom error may be overkill. One can achieve the same with:
if errors.Is(err, NotFound) {
return fmt.Errorf("Business Logic Error: %w", err)
}
fmt.Errorf
with the %w
wrap-verb wraps a new error with the added error message - preserving the original error if one needs to unwrap or use errors.Is
for matching.
In UserHasName
there's a fall-through bug:
if err != nil {
if errors.Is(err, NotFoundError) { return /* */ }
if errors.Is(err, DatabaseError) { return /* */ }
// if err matches neither of the above checks, then the error is lost
}
Applying the above refactoring/bug-fixes: https://play.golang.org/p/N5_PAiJKzRh
-
\$\begingroup\$ Thanks for the example. I agree, that the
fmt.Errorf("Business Logic Error: %w", err)
could partially solve my problem, but as far as I understand in that case I will be forced to keep"Business Logic Error:"
error string in two places:BusinessLogicError = CustomErrorBuilder("BusinessLogicError", nil)
andfmt.Errorf("Business Logic Error: %w", err)
My point was to avoid that duplication and using sentinel-style errors only \$\endgroup\$Serj Piskunov– Serj Piskunov2021年09月02日 08:20:57 +00:00Commented Sep 2, 2021 at 8:20 -
\$\begingroup\$ In the playground link I provided, the error string is defined only once (line 14) and reused (line 35). \$\endgroup\$colm.anseo– colm.anseo2021年09月02日 11:02:58 +00:00Commented Sep 2, 2021 at 11:02
-
\$\begingroup\$ Oh, I see. That makes sense. Thank you! \$\endgroup\$Serj Piskunov– Serj Piskunov2021年09月02日 11:45:55 +00:00Commented Sep 2, 2021 at 11:45