1
\$\begingroup\$

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:

  1. 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.
  2. I want to utilize new errors.Is/As functionality.
  3. I want to hide implementation details of underlying packages. In my code snippet - I don't want the web package to know anything about repository.NotFoundError and repository.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)
 }
 })
 }
}
```
asked Sep 1, 2021 at 8:59
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

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

answered Sep 1, 2021 at 18:55
\$\endgroup\$
3
  • \$\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) and fmt.Errorf("Business Logic Error: %w", err) My point was to avoid that duplication and using sentinel-style errors only \$\endgroup\$ Commented 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\$ Commented Sep 2, 2021 at 11:02
  • \$\begingroup\$ Oh, I see. That makes sense. Thank you! \$\endgroup\$ Commented Sep 2, 2021 at 11:45

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.