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.gofile with defined sentinel-style errors. This, in my opinion, hugely improves readability/maintainability. - I want to utilize new
errors.Is/Asfunctionality. - I want to hide implementation details of underlying packages. In my code snippet - I don't want the
webpackage to know anything aboutrepository.NotFoundErrorandrepository.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)
}
})
}
}
```